From 4ac3f6936b2332dd3349d09f391e7156ed5b8c75 Mon Sep 17 00:00:00 2001 From: Mr_Dwj Date: Wed, 23 Oct 2024 21:03:21 +0800 Subject: [PATCH] Site updated: 2024-10-23 21:03:13 --- Algorithm/a_template/index.html | 16 +- Algorithm/binary-search/index.html | 8 +- Algorithm/data-structure/index.html | 8 +- Algorithm/dfs-and-similar/index.html | 10 +- Algorithm/divide-and-conquer/index.html | 8 +- Algorithm/dp/index.html | 10 +- Algorithm/games/index.html | 8 +- Algorithm/geometry/index.html | 4 +- Algorithm/graphs/index.html | 4 +- Algorithm/greedy/index.html | 8 +- Algorithm/hashing/index.html | 8 +- Algorithm/number-theory/index.html | 8 +- Algorithm/prefix-and-difference/index.html | 8 +- Algorithm/z_logic/index.html | 8 +- BackEnd/Python/Flask/deploy-flask/index.html | 4 +- BackEnd/back-end-guide/index.html | 8 +- DataBase/data-base-guide/index.html | 4 +- .../index.html | 4 +- .../solve-clion-decoding-error/index.html | 8 +- DevTools/DevCpp/devc-self-config/index.html | 8 +- DevTools/Git/git-basic/index.html | 8 +- .../Git/git-self-define-command/index.html | 8 +- .../Hexo/hexo-githubpages-domain/index.html | 4 +- FrontEnd/Hexo/hexo-learning-record/index.html | 4 +- FrontEnd/Hexo/hexo-migrate/index.html | 8 +- FrontEnd/front-end-guide/index.html | 8 +- .../ObjectOrientedClassDesign/index.html | 8 +- GPA/3rd-term/CollegePhysics_2/index.html | 4 +- GPA/3rd-term/DataStructure/index.html | 8 +- .../DataStructureClassDesign/index.html | 4 +- GPA/3rd-term/DigitalLogicCircuit/index.html | 8 +- GPA/3rd-term/LinearAlgebra/index.html | 8 +- GPA/4th-term/MachineLearning/index.html | 558 ++++++------------ GPA/4th-term/OptMethod/index.html | 8 +- GPA/4th-term/ProbAndStat/index.html | 8 +- GPA/4th-term/PyAlgo/index.html | 8 +- GPA/4th-term/PyApply/index.html | 8 +- GPA/4th-term/SysBasic/index.html | 8 +- GPA/5th-term/ComputerOrganization/index.html | 71 ++- GPA/5th-term/DataBase/index.html | 106 ++-- .../NeuralNetworkAndDeepLearning/index.html | 12 +- Operation/operation-guide/index.html | 8 +- README/index.html | 8 +- RoadMap/road-map-guide/index.html | 8 +- archives/2024/03/index.html | 20 +- archives/2024/03/page/2/index.html | 32 +- archives/2024/03/page/3/index.html | 28 +- archives/2024/03/page/4/index.html | 32 +- archives/2024/03/page/5/index.html | 16 +- archives/2024/page/5/index.html | 24 +- archives/2024/page/6/index.html | 32 +- archives/2024/page/7/index.html | 28 +- archives/2024/page/8/index.html | 36 +- archives/2024/page/9/index.html | 12 +- archives/page/5/index.html | 24 +- archives/page/6/index.html | 32 +- archives/page/7/index.html | 28 +- archives/page/8/index.html | 36 +- archives/page/9/index.html | 12 +- categories/Algorithm/index.html | 32 +- categories/Algorithm/page/2/index.html | 16 +- categories/DevTools/Git/index.html | 8 +- categories/DevTools/index.html | 16 +- categories/FrontEnd/Hexo/index.html | 12 +- categories/FrontEnd/page/2/index.html | 12 +- categories/GPA/3rd-term/index.html | 16 +- categories/GPA/4th-term/index.html | 20 +- categories/GPA/page/2/index.html | 32 +- local-search.xml | 286 ++++----- page/15/index.html | 10 +- page/16/index.html | 18 +- page/17/index.html | 24 +- page/18/index.html | 24 +- page/19/index.html | 24 +- page/20/index.html | 16 +- page/21/index.html | 16 +- page/22/index.html | 28 +- page/23/index.html | 10 +- page/24/index.html | 26 +- page/25/index.html | 24 +- page/26/index.html | 24 +- page/27/index.html | 24 +- page/28/index.html | 16 +- page/9/index.html | 2 +- 84 files changed, 1006 insertions(+), 1165 deletions(-) diff --git a/Algorithm/a_template/index.html b/Algorithm/a_template/index.html index b2c669bd9..1a3d9a0bd 100644 --- a/Algorithm/a_template/index.html +++ b/Algorithm/a_template/index.html @@ -27,7 +27,7 @@ - + @@ -320,7 +320,7 @@

a_template

- 本文最后更新于 2024年10月15日 上午 + 本文最后更新于 2024年10月22日 晚上

@@ -424,7 +424,7 @@

树状数组

-
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
+
1
2
3
4
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 BinaryIndexedTree:
def __init__(self, n: int):
"""
初始化序列 O(n)。下标从 1 开始,初始化维护序列区间为 [1,n]。
"""
self.n = n
self.arr = [0] * (n + 1)

def update(self, pos: int, x: int) -> None:
"""
单点修改 O(log n)。在 pos 这个位置加上 x。
"""
while pos <= self.n:
self.arr[pos] += x
pos += self._lowbit(pos)

def query_sum(self, pos: int) -> int:
"""
区间求和 O(log n)。返回 [1,pos] 的区间和。
"""
ret = 0
while pos:
ret += self.arr[pos]
pos -= self._lowbit(pos)
return ret

def _lowbit(self, x: int) -> int:
return x & (-x)
@@ -581,7 +581,7 @@

计算几何

更新于
-
2024年10月15日
+
2024年10月22日
@@ -612,9 +612,9 @@

计算几何

- + - number-theory + binary-search 上一篇 @@ -622,8 +622,8 @@

计算几何

- - README + + data-structure 下一篇 diff --git a/Algorithm/binary-search/index.html b/Algorithm/binary-search/index.html index 75abb27d9..d2b9e06ec 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

- - dfs-and-similar + + a_template 下一篇 diff --git a/Algorithm/data-structure/index.html b/Algorithm/data-structure/index.html index 5d2fab83a..bb7beda1b 100644 --- a/Algorithm/data-structure/index.html +++ b/Algorithm/data-structure/index.html @@ -792,9 +792,9 @@

【线
- + - dfs-and-similar + a_template 上一篇 @@ -802,8 +802,8 @@

【线
- - dp + + divide-and-conquer 下一篇 diff --git a/Algorithm/dfs-and-similar/index.html b/Algorithm/dfs-and-similar/index.html index c234ff68e..99dac8ffa 100644 --- a/Algorithm/dfs-and-similar/index.html +++ b/Algorithm/dfs-and-similar/index.html @@ -857,9 +857,9 @@

【dfs】将
- + - binary-search + dp 上一篇 @@ -867,12 +867,6 @@

【dfs】将 diff --git a/Algorithm/divide-and-conquer/index.html b/Algorithm/divide-and-conquer/index.html index d6194aac8..0c56d65c1 100644 --- a/Algorithm/divide-and-conquer/index.html +++ b/Algorithm/divide-and-conquer/index.html @@ -425,9 +425,9 @@

【分治】随机排列

- + - README + data-structure 上一篇 @@ -435,8 +435,8 @@

【分治】随机排列

- - binary-search + + dp 下一篇 diff --git a/Algorithm/dp/index.html b/Algorithm/dp/index.html index a1d53eff4..92365fc93 100644 --- a/Algorithm/dp/index.html +++ b/Algorithm/dp/index.html @@ -979,9 +979,9 @@

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

【状压dp】Avoid K Palindrome diff --git a/Algorithm/games/index.html b/Algorithm/games/index.html index 2913252d4..f64dc7af6 100644 --- a/Algorithm/games/index.html +++ b/Algorithm/games/index.html @@ -426,9 +426,9 @@

【博弈/贪心/交
- + - z_logic + greedy 上一篇 @@ -436,8 +436,8 @@

【博弈/贪心/交
- - geometry + + hashing 下一篇 diff --git a/Algorithm/geometry/index.html b/Algorithm/geometry/index.html index 85cfb506f..689858f6f 100644 --- a/Algorithm/geometry/index.html +++ b/Algorithm/geometry/index.html @@ -472,9 +472,9 @@

【凸包】奶牛过马路

- + - games + back-end-guide 上一篇 diff --git a/Algorithm/graphs/index.html b/Algorithm/graphs/index.html index ede62bda0..f7fb9067f 100644 --- a/Algorithm/graphs/index.html +++ b/Algorithm/graphs/index.html @@ -704,8 +704,8 @@

【LCA】树的直径

- - hashing + + greedy 下一篇 diff --git a/Algorithm/greedy/index.html b/Algorithm/greedy/index.html index 9418c743a..ff1462ba7 100644 --- a/Algorithm/greedy/index.html +++ b/Algorithm/greedy/index.html @@ -809,9 +809,9 @@

【按位贪心/分类讨论】

- + - hashing + graphs 上一篇 @@ -819,8 +819,8 @@

【按位贪心/分类讨论】

- - prefix-and-difference + + games 下一篇 diff --git a/Algorithm/hashing/index.html b/Algorithm/hashing/index.html index 3093477f5..c5c5ddabf 100644 --- a/Algorithm/hashing/index.html +++ b/Algorithm/hashing/index.html @@ -466,9 +466,9 @@

【哈希/枚举/思维】T
- + - graphs + games 上一篇 @@ -476,8 +476,8 @@

【哈希/枚举/思维】T
- - greedy + + number-theory 下一篇 diff --git a/Algorithm/number-theory/index.html b/Algorithm/number-theory/index.html index 06bc6ba03..b1324c8e2 100644 --- a/Algorithm/number-theory/index.html +++ b/Algorithm/number-theory/index.html @@ -474,9 +474,9 @@

【组合数学】序列数量

- + - prefix-and-difference + hashing 上一篇 @@ -484,8 +484,8 @@

【组合数学】序列数量

- - a_template + + prefix-and-difference 下一篇 diff --git a/Algorithm/prefix-and-difference/index.html b/Algorithm/prefix-and-difference/index.html index e488ac116..50c8a8abd 100644 --- a/Algorithm/prefix-and-difference/index.html +++ b/Algorithm/prefix-and-difference/index.html @@ -463,9 +463,9 @@

【差分/贪心】增减序列

- + - greedy + number-theory 上一篇 @@ -473,8 +473,8 @@

【差分/贪心】增减序列

- - number-theory + + README 下一篇 diff --git a/Algorithm/z_logic/index.html b/Algorithm/z_logic/index.html index a984ee547..a4c338c1d 100644 --- a/Algorithm/z_logic/index.html +++ b/Algorithm/z_logic/index.html @@ -610,9 +610,9 @@

【模拟】同位字
- + - back-end-guide + solve-clion-cannot-open-relative-file 上一篇 @@ -620,8 +620,8 @@

【模拟】同位字
- - games + + back-end-guide 下一篇 diff --git a/BackEnd/Python/Flask/deploy-flask/index.html b/BackEnd/Python/Flask/deploy-flask/index.html index 5e70859d3..fc4d63c69 100644 --- a/BackEnd/Python/Flask/deploy-flask/index.html +++ b/BackEnd/Python/Flask/deploy-flask/index.html @@ -615,8 +615,8 @@

(5) 运行 Flask 应用

- - operation-guide + + road-map-guide 下一篇 diff --git a/BackEnd/back-end-guide/index.html b/BackEnd/back-end-guide/index.html index 9f034144c..acdf61809 100644 --- a/BackEnd/back-end-guide/index.html +++ b/BackEnd/back-end-guide/index.html @@ -423,9 +423,9 @@

参考

- + - solve-clion-cannot-open-relative-file + z_logic 上一篇 @@ -433,8 +433,8 @@

参考

- - z_logic + + geometry 下一篇 diff --git a/DataBase/data-base-guide/index.html b/DataBase/data-base-guide/index.html index 0c80ab752..60a1fbf5a 100644 --- a/DataBase/data-base-guide/index.html +++ b/DataBase/data-base-guide/index.html @@ -421,9 +421,9 @@

参考

- + - solve-clion-decoding-error + self-config 上一篇 diff --git a/DevTools/CLion/solve-clion-cannot-open-relative-file/index.html b/DevTools/CLion/solve-clion-cannot-open-relative-file/index.html index 6797eed18..e8f5cd0e9 100644 --- a/DevTools/CLion/solve-clion-cannot-open-relative-file/index.html +++ b/DevTools/CLion/solve-clion-cannot-open-relative-file/index.html @@ -462,8 +462,8 @@

解决方案

- - back-end-guide + + z_logic 下一篇 diff --git a/DevTools/CLion/solve-clion-decoding-error/index.html b/DevTools/CLion/solve-clion-decoding-error/index.html index afac214f7..4bb0cfbb3 100644 --- a/DevTools/CLion/solve-clion-decoding-error/index.html +++ b/DevTools/CLion/solve-clion-decoding-error/index.html @@ -451,9 +451,9 @@

解决方案

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

解决方案

- - data-base-guide + + self-config 下一篇 diff --git a/DevTools/DevCpp/devc-self-config/index.html b/DevTools/DevCpp/devc-self-config/index.html index 60019aef4..da0d073d5 100644 --- a/DevTools/DevCpp/devc-self-config/index.html +++ b/DevTools/DevCpp/devc-self-config/index.html @@ -452,9 +452,9 @@

四、快捷键选项

- + - git-basic + solve-clion-decoding-error 上一篇 @@ -462,8 +462,8 @@

四、快捷键选项

- - solve-clion-decoding-error + + data-base-guide 下一篇 diff --git a/DevTools/Git/git-basic/index.html b/DevTools/Git/git-basic/index.html index 9bdf6fbba..f9fc1319b 100644 --- a/DevTools/Git/git-basic/index.html +++ b/DevTools/Git/git-basic/index.html @@ -503,9 +503,9 @@

4.5 拉取分支

- + - git-self-define-command + front-end-guide 上一篇 @@ -513,8 +513,8 @@

4.5 拉取分支

- - self-config + + git-self-define-command 下一篇 diff --git a/DevTools/Git/git-self-define-command/index.html b/DevTools/Git/git-self-define-command/index.html index 00f98b8a9..90ada2739 100644 --- a/DevTools/Git/git-self-define-command/index.html +++ b/DevTools/Git/git-self-define-command/index.html @@ -439,9 +439,9 @@

宏定义

- + - front-end-guide + git-basic 上一篇 @@ -449,8 +449,8 @@

宏定义

- - git-basic + + solve-clion-decoding-error 下一篇 diff --git a/FrontEnd/Hexo/hexo-githubpages-domain/index.html b/FrontEnd/Hexo/hexo-githubpages-domain/index.html index 72f6e8747..284e842ce 100644 --- a/FrontEnd/Hexo/hexo-githubpages-domain/index.html +++ b/FrontEnd/Hexo/hexo-githubpages-domain/index.html @@ -458,9 +458,9 @@

第三步

- + - ObjectOrientedClassDesign + hexo-migrate 上一篇 diff --git a/FrontEnd/Hexo/hexo-learning-record/index.html b/FrontEnd/Hexo/hexo-learning-record/index.html index f76b14eca..d16201327 100644 --- a/FrontEnd/Hexo/hexo-learning-record/index.html +++ b/FrontEnd/Hexo/hexo-learning-record/index.html @@ -537,8 +537,8 @@

参考

- - hexo-migrate + + front-end-guide 下一篇 diff --git a/FrontEnd/Hexo/hexo-migrate/index.html b/FrontEnd/Hexo/hexo-migrate/index.html index 21a2cd9a8..97b4dd053 100644 --- a/FrontEnd/Hexo/hexo-migrate/index.html +++ b/FrontEnd/Hexo/hexo-migrate/index.html @@ -441,9 +441,9 @@

缺点

- + - hexo-learning-record + ObjectOrientedClassDesign 上一篇 @@ -451,8 +451,8 @@

缺点

- - front-end-guide + + hexo-githubpages-domain 下一篇 diff --git a/FrontEnd/front-end-guide/index.html b/FrontEnd/front-end-guide/index.html index a5028d974..826f39f20 100644 --- a/FrontEnd/front-end-guide/index.html +++ b/FrontEnd/front-end-guide/index.html @@ -443,9 +443,9 @@

参考

- + - hexo-migrate + hexo-learning-record 上一篇 @@ -453,8 +453,8 @@

参考

- - git-self-define-command + + git-basic 下一篇 diff --git a/GPA/2nd-term/ObjectOrientedClassDesign/index.html b/GPA/2nd-term/ObjectOrientedClassDesign/index.html index 0b3b6dc2a..459c8590e 100644 --- a/GPA/2nd-term/ObjectOrientedClassDesign/index.html +++ b/GPA/2nd-term/ObjectOrientedClassDesign/index.html @@ -437,9 +437,9 @@

代码仓库

- + - LinearAlgebra + DigitalLogicCircuit 上一篇 @@ -447,8 +447,8 @@

代码仓库

- - hexo-githubpages-domain + + hexo-migrate 下一篇 diff --git a/GPA/3rd-term/CollegePhysics_2/index.html b/GPA/3rd-term/CollegePhysics_2/index.html index a729ece40..d7daa86f5 100644 --- a/GPA/3rd-term/CollegePhysics_2/index.html +++ b/GPA/3rd-term/CollegePhysics_2/index.html @@ -1475,8 +1475,8 @@

13.6.3 卡诺定理

- - DigitalLogicCircuit + + DataStructure 下一篇 diff --git a/GPA/3rd-term/DataStructure/index.html b/GPA/3rd-term/DataStructure/index.html index 92cfefd15..a4fab78cc 100644 --- a/GPA/3rd-term/DataStructure/index.html +++ b/GPA/3rd-term/DataStructure/index.html @@ -1405,9 +1405,9 @@

10.8 归并排序

- + - MachineLearning + CollegePhysics_2 上一篇 @@ -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 28dea5bd0..6f668434a 100644 --- a/GPA/3rd-term/DataStructureClassDesign/index.html +++ b/GPA/3rd-term/DataStructureClassDesign/index.html @@ -601,9 +601,9 @@

仓库地址

- + - DataStructure + SysBasic 上一篇 diff --git a/GPA/3rd-term/DigitalLogicCircuit/index.html b/GPA/3rd-term/DigitalLogicCircuit/index.html index 2487e04c2..7c1d6564e 100644 --- a/GPA/3rd-term/DigitalLogicCircuit/index.html +++ b/GPA/3rd-term/DigitalLogicCircuit/index.html @@ -1525,9 +1525,9 @@

6.2 计数器

- + - CollegePhysics_2 + LinearAlgebra 上一篇 @@ -1535,8 +1535,8 @@

6.2 计数器

- - LinearAlgebra + + ObjectOrientedClassDesign 下一篇 diff --git a/GPA/3rd-term/LinearAlgebra/index.html b/GPA/3rd-term/LinearAlgebra/index.html index 7ba7a6d80..0a8ca7afd 100644 --- a/GPA/3rd-term/LinearAlgebra/index.html +++ b/GPA/3rd-term/LinearAlgebra/index.html @@ -1173,9 +1173,9 @@

行列式角度

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

行列式角度

- - ObjectOrientedClassDesign + + DigitalLogicCircuit 下一篇 diff --git a/GPA/4th-term/MachineLearning/index.html b/GPA/4th-term/MachineLearning/index.html index 8ce281fe7..efcd1264f 100644 --- a/GPA/4th-term/MachineLearning/index.html +++ b/GPA/4th-term/MachineLearning/index.html @@ -19,30 +19,19 @@ - + - + - - - - - - - - - - - @@ -104,11 +93,11 @@ - + - + @@ -323,7 +312,7 @@ @@ -334,7 +323,7 @@ - 144 分钟 + 139 分钟 @@ -398,7 +387,7 @@

MachineLearning

- 本文最后更新于 2024年10月10日 下午 + 本文最后更新于 2024年10月23日 早上

@@ -406,112 +395,21 @@

MachineLearning

-

机器学习与模式识别

+

机器学习

前言

-

学科地位:

+

本博客初稿完成与大二下学期,记录 Machine Learning 相关内容。主要参考 《机器学习与模式识别》· 周志华 · 清华大学出版社 和 南瓜书 《机器学习》(西瓜书)公式详解

+

1 绪论

- - - - - - - - - - - - -
主讲教师学分配额学科类别
杨琬琪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 基本术语

- - - - - + + - + @@ -547,11 +445,11 @@

1.2 基本术语

- + - + @@ -567,127 +465,100 @@

1.2 基本术语

NameIntroduction术语含义
机器学习定义利用经验改善系统自身性能,主要研究智能数据分析的理论和方法。利用 经验 改善系统自身性能,主要研究 智能数据分析 的理论和方法。
计算学习理论
样本<x>< x >
样例<x,y>< x, y >
预测任务
-

1.3 假设空间

-

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

-

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

-

1.4 归纳偏好

-

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

-

1.5 发展历程

-

pass

-

1.6 应用现状

-

pass

-

第2章 模型评估与选择

-

2.1 经验误差与过拟合

-

概念辨析

+

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

+

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

+

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

+

2 模型评估与选择

+

2.1 误差与过拟合

+

误差有以下两种:

    -
  • 错误率:针对测试数据而言,分错的样本数 aa 占总样本数 mm 的比例 E=amE=\frac{a}{m}
  • -
  • 经验误差:针对训练数据而言,随着训练轮数或模型的复杂度越高,经验误差越小
  • +
  • 测试误差。针对测试数据而言,分错的样本数 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 思想 (对同一样本用多个模型投票产生结果)

    +

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

  • -

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

    +

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

  • -

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

    +

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

  • -
-

欠拟合解决方法

-
  • -

    决策树:拓展分支

    +

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

  • -

    神经网络:增加训练轮数

    +

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

+ +
+ +
+
+

在损失函数中添加正则化到底有什么好处?

  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}

+
+
+

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' 作为测试集即可

    - +

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

    +
    -
  • -

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}

+

均方根误差

+

RMSE=1mi=1m(f(xi)yi)2RMSE=\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

+219 661 l218 661zM702 80H400000v40H742z">

+

R2R^2 分数

+

R2=1i=1m(f(xi)yi)2i=1m(yˉyi)2,yˉ=1mi=1myiR^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 +

- diff --git a/archives/2024/page/8/index.html b/archives/2024/page/8/index.html index 3a069ae89..c2063344b 100644 --- a/archives/2024/page/8/index.html +++ b/archives/2024/page/8/index.html @@ -248,33 +248,27 @@

2024

- - -
geometry
-
- -
graphs
- + -
hashing
+
greedy
- + -
greedy
+
games
- + -
prefix-and-difference
+
hashing
@@ -284,9 +278,9 @@ - + -
a_template
+
prefix-and-difference
@@ -296,15 +290,21 @@ - + -
divide-and-conquer
+
binary-search
- + -
binary-search
+
a_template
+
+ + + + +
data-structure
diff --git a/archives/2024/page/9/index.html b/archives/2024/page/9/index.html index 78eebb6e6..925ea1703 100644 --- a/archives/2024/page/9/index.html +++ b/archives/2024/page/9/index.html @@ -248,21 +248,21 @@

2024

- + -
dfs-and-similar
+
divide-and-conquer
- + -
data-structure
+
dp
- + -
dp
+
dfs-and-similar
diff --git a/archives/page/5/index.html b/archives/page/5/index.html index 8ddf5050f..c8d1eba28 100644 --- a/archives/page/5/index.html +++ b/archives/page/5/index.html @@ -266,15 +266,15 @@ - + -
operation-guide
+
road-map-guide
- + -
road-map-guide
+
operation-guide
@@ -284,27 +284,27 @@ - + -
OptMethod
+
MachineLearning
- + -
ProbAndStat
+
PyAlgo
- + -
PyAlgo
+
ProbAndStat
- + -
SysBasic
+
OptMethod
diff --git a/archives/page/6/index.html b/archives/page/6/index.html index 046fc7404..ea7ef5441 100644 --- a/archives/page/6/index.html +++ b/archives/page/6/index.html @@ -248,15 +248,9 @@

2024

- + -
MachineLearning
-
- - - - -
DataStructure
+
SysBasic
@@ -272,9 +266,9 @@ - + -
DigitalLogicCircuit
+
DataStructure
@@ -284,27 +278,33 @@ + + +
DigitalLogicCircuit
+
+ +
ObjectOrientedClassDesign
- + -
hexo-githubpages-domain
+
hexo-migrate
- + -
hexo-learning-record
+
hexo-githubpages-domain
- + -
hexo-migrate
+
hexo-learning-record
diff --git a/archives/page/7/index.html b/archives/page/7/index.html index b96758ea4..28d7c50f8 100644 --- a/archives/page/7/index.html +++ b/archives/page/7/index.html @@ -254,21 +254,15 @@ - - -
git-self-define-command
-
- -
git-basic
- + -
self-config
+
git-self-define-command
@@ -278,6 +272,12 @@ + + +
self-config
+
+ +
data-base-guide
@@ -290,21 +290,21 @@
- + -
back-end-guide
+
z_logic
- + -
z_logic
+
back-end-guide
- + -
games
+
geometry
diff --git a/archives/page/8/index.html b/archives/page/8/index.html index 87be412ea..e7b8eef6f 100644 --- a/archives/page/8/index.html +++ b/archives/page/8/index.html @@ -248,33 +248,27 @@

2024

- - -
geometry
-
- -
graphs
- + -
hashing
+
greedy
- + -
greedy
+
games
- + -
prefix-and-difference
+
hashing
@@ -284,9 +278,9 @@ - + -
a_template
+
prefix-and-difference
@@ -296,15 +290,21 @@ - + -
divide-and-conquer
+
binary-search
- + -
binary-search
+
a_template
+
+ + + + +
data-structure
diff --git a/archives/page/9/index.html b/archives/page/9/index.html index 497bf2229..a4d202c44 100644 --- a/archives/page/9/index.html +++ b/archives/page/9/index.html @@ -248,21 +248,21 @@

2024

- + -
dfs-and-similar
+
divide-and-conquer
- + -
data-structure
+
dp
- + -
dp
+
dfs-and-similar
diff --git a/categories/Algorithm/index.html b/categories/Algorithm/index.html index 7d3a3d07d..d47486295 100644 --- a/categories/Algorithm/index.html +++ b/categories/Algorithm/index.html @@ -254,12 +254,6 @@ - - -
games
-
- -
geometry
@@ -272,21 +266,21 @@
- + -
hashing
+
greedy
- + -
greedy
+
games
- + -
prefix-and-difference
+
hashing
@@ -296,15 +290,21 @@ - + -
a_template
+
prefix-and-difference
- + -
divide-and-conquer
+
binary-search
+
+ + + + +
a_template
diff --git a/categories/Algorithm/page/2/index.html b/categories/Algorithm/page/2/index.html index 9c74d067a..0d2b272eb 100644 --- a/categories/Algorithm/page/2/index.html +++ b/categories/Algorithm/page/2/index.html @@ -248,27 +248,27 @@

2024

- + -
binary-search
+
data-structure
- + -
dfs-and-similar
+
divide-and-conquer
- + -
data-structure
+
dp
- + -
dp
+
dfs-and-similar
diff --git a/categories/DevTools/Git/index.html b/categories/DevTools/Git/index.html index 97de3332a..305c0a1ab 100644 --- a/categories/DevTools/Git/index.html +++ b/categories/DevTools/Git/index.html @@ -260,15 +260,15 @@ - + -
git-self-define-command
+
git-basic
- + -
git-basic
+
git-self-define-command
diff --git a/categories/DevTools/index.html b/categories/DevTools/index.html index 4326874b7..eaca892b6 100644 --- a/categories/DevTools/index.html +++ b/categories/DevTools/index.html @@ -278,21 +278,15 @@ - - -
git-self-define-command
-
- -
git-basic
- + -
self-config
+
git-self-define-command
@@ -302,6 +296,12 @@ + + +
self-config
+
+ +
solve-clion-cannot-open-relative-file
diff --git a/categories/FrontEnd/Hexo/index.html b/categories/FrontEnd/Hexo/index.html index ef143bf9c..9b7ba0c1c 100644 --- a/categories/FrontEnd/Hexo/index.html +++ b/categories/FrontEnd/Hexo/index.html @@ -266,21 +266,21 @@
- + -
hexo-githubpages-domain
+
hexo-migrate
- + -
hexo-learning-record
+
hexo-githubpages-domain
- + -
hexo-migrate
+
hexo-learning-record
diff --git a/categories/FrontEnd/page/2/index.html b/categories/FrontEnd/page/2/index.html index 47ac54628..7b1f308f7 100644 --- a/categories/FrontEnd/page/2/index.html +++ b/categories/FrontEnd/page/2/index.html @@ -248,21 +248,21 @@

2024

- + -
hexo-githubpages-domain
+
hexo-migrate
- + -
hexo-learning-record
+
hexo-githubpages-domain
- + -
hexo-migrate
+
hexo-learning-record
diff --git a/categories/GPA/3rd-term/index.html b/categories/GPA/3rd-term/index.html index 566dd2d60..648d7f206 100644 --- a/categories/GPA/3rd-term/index.html +++ b/categories/GPA/3rd-term/index.html @@ -248,12 +248,6 @@

2024

- - -
DataStructure
-
- -
DataStructureClassDesign
@@ -266,9 +260,9 @@
- + -
DigitalLogicCircuit
+
DataStructure
@@ -277,6 +271,12 @@
LinearAlgebra
+ + + +
DigitalLogicCircuit
+
+ diff --git a/categories/GPA/4th-term/index.html b/categories/GPA/4th-term/index.html index d33fe7bea..bf6eb2d5a 100644 --- a/categories/GPA/4th-term/index.html +++ b/categories/GPA/4th-term/index.html @@ -260,33 +260,33 @@ - + -
OptMethod
+
MachineLearning
- + -
ProbAndStat
+
PyAlgo
- + -
PyAlgo
+
ProbAndStat
- + -
SysBasic
+
OptMethod
- + -
MachineLearning
+
SysBasic
diff --git a/categories/GPA/page/2/index.html b/categories/GPA/page/2/index.html index 0b7f88995..9ac31820d 100644 --- a/categories/GPA/page/2/index.html +++ b/categories/GPA/page/2/index.html @@ -248,15 +248,9 @@

2024

- - -
OptMethod
-
- - - + -
ProbAndStat
+
MachineLearning
@@ -266,21 +260,21 @@ - + -
SysBasic
+
ProbAndStat
- + -
MachineLearning
+
OptMethod
- + -
DataStructure
+
SysBasic
@@ -296,9 +290,9 @@ - + -
DigitalLogicCircuit
+
DataStructure
@@ -307,6 +301,12 @@
LinearAlgebra
+ + + +
DigitalLogicCircuit
+
+ diff --git a/local-search.xml b/local-search.xml index 736da01ad..4d6da21f1 100644 --- a/local-search.xml +++ b/local-search.xml @@ -493,7 +493,7 @@ /GPA/5th-term/ComputerOrganization/ - 计算机组成原理

前言

学科地位:

主讲教师学分配额学科类别
闫文珠3.5自发课

成绩组成:

作业+实验期末
50%50%

教材情况:

课程名称选用教材版次作者出版社ISBN 号
计算机组成原理《计算机组成与系统结构》3袁春风清华大学出版社978-7-302-59988-3

最基本的知识普及:

主板(Motherboard)

  • 作用:主板是所有硬件的连接平台,它连接并协调 CPU、内存、存储设备、显卡、网络设备和其他外部接口。主板上还包含芯片组,负责管理数据传输和设备间的通信。
  • 与其他部分的关系:主板是各个硬件组件的核心枢纽,确保数据能够在各个组件之间高效传输和通信。

机箱视角中的主板

主板的 6 大区域

输入/输出设备(I/O Devices)

  • 作用:输入设备(如键盘、鼠标、触摸屏)用于用户与计算机的交互。输出设备(如显示器、打印机)用于显示计算机的操作结果。网络设备(如网卡、Wi-Fi 模块)则允许电脑连接到网络。
  • 与其他部分的关系:输入设备通过主板将用户的操作传递给 CPU 进行处理,输出设备则从 CPU 或显卡接收处理后的数据进行展示。

I/O 区域

中央处理器(CPU)

  • 作用:CPU 是电脑的大脑,负责执行计算任务和指令。它从内存中读取指令,进行运算,然后将结果写回内存。现代 CPU 具有多个核心,可以并行处理多项任务,提高计算效率。
  • 与其他部分的关系:CPU 与内存和存储设备密切配合。它从内存中获取数据和指令,然后进行处理,最终将结果写回内存或存储设备。

CPU 区域

内存(RAM)

  • 作用:内存是计算机中的临时存储器,用于存储当前运行的程序和数据。它的访问速度极快,但数据在断电后会丢失。内存用于支持 CPU 的高速操作。
  • 与其他部分的关系:内存是 CPU 执行任务时的工作区域,存储从存储设备(如硬盘)中读取的数据和指令。内存的数据会在需要时被写回到存储设备中。

内存区域

显卡(GPU)

  • 作用:显卡负责处理图形计算任务,特别是在图像和视频渲染、游戏以及某些并行计算任务中(如深度学习)。高性能显卡能够加速这些计算任务。
  • 与其他部分的关系:显卡通过主板与 CPU 和内存通信,从内存中读取图形数据,并进行处理,然后将渲染结果输出到显示器。

存储设备(Storage Devices)

  • 作用:存储设备如机械硬盘 hard disk (HDD)、固态硬盘 solid state disk (SSD),用于长期保存数据和程序,即使在电脑断电后数据也不会丢失。SSD 的速度比传统硬盘更快,因此越来越常用。
  • 与其他部分的关系:存储设备保存操作系统、应用程序和用户数据。程序执行时,数据会从存储设备加载到内存中供 CPU 使用。

网卡(Network Interface Card, NIC)

  • 作用:网卡是计算机与网络之间的桥梁,负责处理计算机与网络间的数据通信。它将计算机的数据转换为网络信号,以太网网卡通过有线或无线方式连接局域网(LAN)或互联网。
  • 与其他部分的关系:网卡通过主板与南桥芯片组或 PCIe 总线连接,接收来自 CPU 的数据,经过处理后通过网络接口发送到外部网络。同时,它也接收来自网络的数据,传递给 CPU 进行处理,确保计算机能够与其他网络设备进行通信。

扩展区域

南桥芯片组(Southbridge)

  • 作用:南桥芯片组是主板上的一个重要芯片,为了避免过多的模块与 CPU 直连导致主板排线困难以及布局问题,产生了南桥芯片组用于负责管理较慢的外围设备的连接和数据传输。包括 USB 接口、SATA 接口、网络接口、音频设备和其他 I/O 设备。南桥还处理 BIOS、系统时钟、硬盘和光驱的控制。
  • 与其他部分的关系:南桥通过主板与 CPU 和北桥芯片组(或直接与 CPU)连接,接收来自 CPU 的指令,并将其转化为外设可以理解的信号。它还通过 I/O 总线连接各种外设,确保数据在外围设备与系统之间顺畅传输。

南桥芯片组区域

BIOS(Basic Input/Output System)

  • 作用:BIOS 是主板的固件程序,它存储在主板的 BIOS 芯片里。负责在计算机启动时进行硬件初始化,指导所有的硬件运行,并启动操作系统。
  • 与其他部分的关系:BIOS 在开机时检查并初始化硬件,然后引导操作系统,从而开始系统的工作。

BIOS 芯片

为什么要学这门课?

上上学期学习了《数字逻辑电路》,云里雾里;上学期学习了《计算机系统基础》,继续云里雾里。本学期开始学习《计算机组成原理》?如果用一句话来概括我对数字逻辑电路的理解,大概可以这样说:原来计算机是一个由“门电路”、“导线”和“时钟脉冲”组成的机器。如果用一句话来概括我对计算机系统基础的理解,大概可以这样说:原来计算机所有的活动都是由翻译过来的 01 序列驱动的。

现在我们让 AI 模仿上面我对课程理解的概括逻辑,用一句话来概括计算机组成原理,ta 是这样回答的:原来计算机的高效运作是通过硬件架构的精妙设计,将复杂的指令和数据流转化为有序的电子信号运动来实现的

会收获什么?

对计算机整体有一个宏观的把握,了解 CPU、存储和 I/O 设备的工作逻辑,尤其要熟悉单核 CPU 的工作逻辑。最后能够利用 verilog 硬件描述语言从逻辑上实现一个单核 CPU。

注:

本书 ISA 采用 MIPS 指令系统。之前学习的 《计算机系统基础》 中 ISA 采用的是 IA-32 指令系统,也就是大名鼎鼎的 x86-64 指令系统的前身。

为了形式化地描述 CPU 的运行逻辑,需要使用寄存器传送级 (register transfer level, 简称 RTL) 语言。本书 RTL 语言有以下规定:R [r] 表示通用寄存器 r 的内容,M [addr] 表示存储单元 addr 的内容;M [R[r]] 表示寄存器 r 的内容所指存储单元的内容;PC 表示 PC 的内容,M [PC] 表示 PC 所指存储单元的内容;SEXT [imm] 表示对 imm 进行符号扩展,ZEXT [imm] 表示对 imm 进行零扩展;传送方向用 \leftarrow 表示,即传送源在右,传送目的在左。

绪论

1 计算机系统概述

注:本章与《计算机系统基础》的第 1 章重复,详见 https://blog.dwj601.cn/GPA/4th-term/SysBasic/#第1章-计算机系统概述

主要掌握 冯诺依曼状态机计算机性能度量 两个知识点。

2 数据的机器级表示

注:本章与《计算机系统基础》的第 2 章重复,详见 https://blog.dwj601.cn/GPA/4th-term/SysBasic/#第2章-数据的机器级表示与处理

主要掌握 数值/非数值数据的表示数据宽度存储对齐纠/检错 四个知识点。

数据的纠错/检错见 7.4 节。

CPU

3 运算方法和运算部件

本章我们讲讲 CPU 中的 算数逻辑单元(Arithmetic and Logic Unit,简称 ALU)。ALU 在计算机中的地位大致如下图所示:

graph RL    实现功能    执行程序    机器指令    subgraph ALU    direction RL    算数运算逻辑    subgraph 运算部件    direction LR    加法器    移位器    end    end        运算部件 -->|硬件支持| 算数运算逻辑    ALU --> 机器指令    机器指令 --> 执行程序    执行程序 --> 实现功能

也就有了这样的学习路线:涉及运算的机器指令(MIPS 指令系统) \to 运算部件支持(逻辑电路图) \to 算数运算逻辑(算法设计)

3.1 涉及运算的机器指令

都有哪些涉及运算的机器指令?机器指令的种类有很多,我们在计算机系统基础中已经学习到了比如:数据传输指令、控制指令、跳转指令等等。由于本章讲解的是算数运算,因此我们主要学习涉及到运算的机器指令。我们将所有涉及到运算的机器指令抽丝剥茧,凝练出了以下共 11 条指令:

11 条涉及运算指令的 RTL 描述

11 条涉及运算指令的 RTL 描述 - 续

不难发现,上述 11 种除了最后一个跳转指令,其余的 10 条运算指令只有 3 大类:加减法、按位或、小于置 1。

3.2 运算部件支持

有了上述的程序运算需求,如何设计出对应的硬件来支持呢?CPU 中的 ALU 单元就是负责运算的部件。ALU 内部的逻辑结构如下图所示,其输入有两个运算数 A 和 B 以及一个控制输入 ALUctr:

ALU 实现

ALUctr 有 4 个控制信号,下面借书中原文分别解释 4 个控制信号对应的逻辑:

SUBctr 用来控制 ALU 执行加法还是减法运算。当 SUBctr = 1 时,做减法;当 SUBctr = 0 时,做加法。

OPctr 用来控制选择哪种运算的结果作为 Result 输出。因为所实现的 11 条指令中只可能有加/减、按位或、小于置 1 这 3 大类运算,所以 OPctr 有两位。

OVctr 用来控制是否要进行溢出判断。当 OVctr = 1 时,进行溢出判断,此时,若结果发生溢出,则溢出标志 Overflow 为 1,当 OVctr = 0 时,无须溢出判断,此时,即使结果发生溢出,溢出标志 Overflow 也不为 1。

SIGctr 信号控制 ALU 是执行「带符号整数比较小于置 1」还是执行「无符号数比较小于置 1」的功能。当 SIGctr = 0,执行「无符号数比较小于置 1」的功能;当 SIGctr = 1 时,执行「带符号整数比较小于置 1」的功能。

完美实现了所需的 3 大类运算逻辑。

ALU 是如何控制以精准实现上述 11 条指令的?本质上就是通过 ALUctr 来实现运算控制的。除了最后一个跳转,难道 10 个指令就一定需要 log2(10)=4\lceil \log_2(10) \rceil =4 位进行信号选择控制吗?并不是,其中一些虽然功能不同,但是运算逻辑相同:

  • 指令 addiu、lw、sw 和 beq 转移目标地址计算的 ALU 控制信号取值一样,都是进行加法运算并不判溢出,记为 addu 操作.
  • 指令 subu 和 beq 判 0 操作的 ALU 控制信号可看成一样,都做减法运算并不判溢出,记为 subu 操作。

下表详细解释了每一条指令的运算类型与 ALUctr 取值之间的关系:

运算类型与 ALUctr 取值之间的关系

因此,这 11 条指令可以归纳为 7 种操作:addu、add、or、subu、sub、sltu、slt,也就是说 ALUctr 只需要 log2(7)=3\lceil \log_2(7) \rceil =3 个选择控制位!如下表所示,列出了 ALUctr 的选择控制逻辑:

ALUctr 的选择控制逻辑

为什么可以这样控制

加减运算控制(SUBctr):很显然,加法(SUBctr = 0),减法(SUBctr = 1)。

输出内容控制(OPctr):很显然, 3 大类运算就对应 3 个取值。

溢出判断控制(OVctr):很显然,有符号运算需要溢出判断,其他运算都不需要。

小于置一控制(SIGctr):这个逻辑比较有意思。为什么无符号数比大小时 Less = Carry ^ SUBctr;有符号数比大小时 Less = Sign ^ Overflow 呢?首先我们知道,比大小的本质是使用加法器做减法运算,那么 AB=A+B=A+(B+1)A-B=A+B_{\text{补}}=A+(\sim B +1)

  • 对于无符号数的比大小逻辑。由于是减法,SUBctr 一定是 1,因此此时的输出其实可以进一步归纳为 Less = Carry ^ 1 = !Carry。也就是说我们只需要分析加法运算的进位结果即可,而这就是之前学过的无符号整数加减运算逻辑了。如果 A 严格小于 B,则 B 的补码与 A 相加后不会产生进位,此时 !Carry = !0 = 1 表示 A<BA<B;如果 ABA\ge B,则 B 的补码与 A 相加后就会超过无符号整数表示的范围产生进位,此时 !Carry = !1 = 0 表示 ABA \ge B。很巧妙的逻辑。
  • 对于有符号数的比大小逻辑。
    • 如果运算没有溢出,即 Overflow=0,此时 A 与 B 的正负一定相同。A 与 B 的比大小结果可以直接根据加法器运算结果的符号位来确定。如果运算结果是负的,即 Sign=1,那么显然 A<BA<B;反之如果运算结果是正的,即 Sign=0,那么显然 ABA\ge B
    • 如果运算发生溢出,即 Overflow=1,此时 A 与 B 的正负一定不同。但我们不知道谁正谁负,根据有符号整数加减运算的溢出符号判定逻辑可知:
      • 若 A 为正数 B 为负数。溢出发生时运算结果一定是负数(正溢出),即 Sign=1,此时 Less = Sign ^ OverFlow = 0,即 ABA \ge B
      • 若 A 为负数 B 为正数,溢出发生时运算结果一定是正数(负溢出),即 Sign=0,此时 Less = Sign ^ OverFlow = 1,即 A<BA< B

3.3 算数运算逻辑

程序的运算需求有了(算数运算),能进行基本运算的硬件也设计出来了(ALU)。如何利用已有的运算部件来巧妙地设计算法以高效地实现我们常见的数学运算呢?让我们一探究竟!

3.3.1 整数加减运算

不重复造轮子,见上学期记的笔记:https://blog.dwj601.cn/GPA/4th-term/SysBasic/#2-7-4-整数加减运算

3.3.2 原码乘法运算

怎么算的?按照手算乘法的逻辑进行运算。但是在计算机内部进行了一定的改进,有三点:

  1. 并不是全部算完每一步的乘法结果后再一次性累加,而是使用一个 “局部变量” 保存前缀和(部分积);
  2. 由于每一位的乘法结果都是在最高位进行的,因此我们不是对当前的乘法运算结果左移一位,而是将前面计算出的前缀和右移一位;
  3. 由于单步乘法运算时只有 0 和 1,显然若当前为 0 则对答案的贡献也为 0,因此当乘法位为 0 时只需要前缀和右移一位即可,而不需要执行相加操作。

其实算法过程很简单,就是模拟了乘法运算的过程,这里就不罗列了。只不过其中有一些关于运算部件的巧妙利用。比如将每次部分积右移后多出来的一位存放到 Y 中,反正 Y 右移后的最后一位已经没用并且舍弃掉了,前面空出来的一位正好就用来存储部分积的最后一位。

我们将二进制位从低到高的下标从 1 开始计数,进位位记作 C,部分积记作 P,乘数位记作 Y,则有这样的递推式:

Pi=(Pi1+Xyi)1,P0=0P_i = (P_{i-1}+Xy_i) \gg 1,\quad P_0 = 0

模拟过程如下:

算例

有哪些应用场景?浮点数尾数运算。在浮点数的尾数运算中,对数值位直接使用原码乘法即可,符号位就是两个乘数的符号位相异或的结果,例如:设 [x]=0.1110[x]_\text{原}=0.1110[y]=1.1101[y]_\text{原}=1.1101,计算 [x×y][x\times y]_\text{原},符号位为 01=10\oplus 1=1,数值位为 [x]×[y][x]_\text{原} \times [y]_\text{原},即 1110×11011110 \times 1101 的原码乘法运算结果 1011011010110110

可以优化吗?分块思想。由于是逐位运算,因此我们需要进行 nn 次相乘再相加的操作,时间复杂度为 O(n)O(n)。现有的优化方案就是逐 kk 位运算,那么时间复杂度就可以优化为 O(nk)O(\frac{n}{k})

3.3.3 补码乘法运算

怎么算的?如何在已知 [X][X]_{\text{补}}[Y][Y]_{\text{补}} 的情况下,计算 [X×Y][X\times Y]_{\text{补}}?由于 [X×Y][X]×[Y][X\times Y]_{\text{补}} \ne [X]_{\text{补}} \times [Y]_{\text{补}},因此补码乘法不能直接使用原码乘法的算法,需要我们重新设计运算方法,这里引入 Booth 算法。

布斯算法的本质是将符号位与数值位一起运算,也就是对有符号数的一次性运算算法。如下推导:

推导

进而可以得到关于「真值」的部分积递推公式:

Pi=[Pi1+(yi2yi1)X]1P_{i} = [P_{i-1} + (y_{i-2}-y_{i-1})X] \gg 1

于是可以得到关于「补码」的部分积递推公式:

[Pi]=[Pi1+(yi2yi1)X]1=[Pi1]1+[(yi2yi1)X]1\begin{aligned}[P_{i}] _{\text{补}} &= [P_{i-1} + (y_{i-2}-y_{i-1})X]_{\text{补}} \gg 1 \\&= [P_{i-1}] _{\text{补}} \gg 1 + [(y_{i-2}-y_{i-1})X]_{\text{补}} \gg 1\end{aligned}

显然的 yi2yi1y_{i-2}-y_{i-1} 只有 1,0,1-1,0,1 共 3 种情况,因此我们只需要知道 [X]-[X]_{\text{补}}[X][X]_{\text{补}} 即可利用移位和加法快速运算。例如下面的算例:

算例

有哪些应用场景?很多。由于计算机中的数据都是以补码形式存储,因此补码乘法的使用场景更加广泛。

可以优化吗?分块思想。与原码乘法的优化方案类似。

4 指令系统

5 中央处理器

6 指令流水线

存储

7 存储器

当机器有了记忆,就可以不用人为干预而自动运行,从而真正意义上实现了自动化的流程。本章主要学习计算机中的存储器件、存储策略以及数据交互逻辑。

现代计算机中有很多类型的存储器,核心功能都是存储数据,那为什么不统一成一种存储器呢?根本原因是 CPU 的计算速度远大于从存储器中访存数据的速度,因此我们不得不设计出可以匹配 CPU 计算速度的存储器结构。但这样的存储器造价极高并且存储量很小,因此我们只能退而求其次,从而诞生了现代计算机中的层次存储结构。从 CPU 开始依次为:寄存器 (Register)缓存 (Cache)内存 (Main Memory)外存 (Secondary Memory)。这些存储器的访存速度逐渐降低。本章也将按照这样的顺序分别讲解相应的概念。最后会补充介绍一下存储器中的 虚拟存储 理念以及 数据校验 策略。

7.1 缓存

缓存采用的随机存取存储器是 SRAM,即静态随机存取存储器。由于其速度快容量小且造价很高,因此适合做 cache 工作。SRAM 存储高低电平的方式是触发器。

7.1.1 程序的局部性

为什么会有缓存?这是基于什么特性才产生的?程序的局部性。

程序为什么具有局部性?从高级语言的逻辑进行理解不难发现,数据的重复访问往往集中在一个程序段中,而对应的指令和数据是连续存储的,因此程序在内存中运行时具有局部性。不断执行的某些指令和不断访问的某些数据就可以存入缓存中。这里根据局部性再衍生出「空间局部性」和「时间局部性」两个概念。

  • 空间局部性简单来讲就是某些指令或数据「存储顺序和访问顺序的差异」。差异越小,空间局部性就越好。
  • 时间局部性简单来讲就是某些指令或数据「短时间内重复访问的情况」。重复访问的越多,时间局部性就越好。
7.1.2 cache 工作逻辑

分块思想。cache 一般被组装在 CPU 内部,便于和寄存器进行高效的数据交互。与此同时,设计者将 cache 和内存进行了划分,使得两者被划分为由相同大小存储空间组成的存储器,在 cache 中被称为槽(slot),在内存中被称为块(block)。为了知道 cache 中每一个槽中是否有有效缓存,设计者对 cache 中的每一个槽设定了一个有效位来区分是否存储了有效缓存数据。下图给出了 cache 读数据的工作流程:

cache 读数据的工作流程

示例解释。有了上述对 cache 和内存「分块」的思想,也就可以解释从大一一开始就提到的,下面两个程序执行时间天壤之别的原因:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int a[M][N];

// 更快
int s = 0;
for (int i = 0; i < M; i++) {
for (int j = 0; j < N; j++) {
s += A[i][j];
}
}

// 很慢
int t = 0;
for (int j = 0; j < N; j++) {
for (int i = 0; i < M; i++) {
s += A[i][j];
}
}

我们知道上述两个程序段的指令数量很少,就忽略指令的局部性带来的差异,仅仅考虑数据的局部性差异。由于对数据进行访问时,不同的数据仅仅是地址的差异,而每一个数据的地址都是用加法器一步运算出来的,按道理并不会有任何时间开销的差异。那这几十倍的时间开销差异从何而来?其实就是缓存的功劳。当然如果数据的大小比一个「槽」或「块」还要小,那就利用不上缓存机制了,当然这么小的数据也不会因为没有缓存而拖慢程序运行时间。

缓存是物理存在但逻辑透明的。从上述讨论可以发现,缓存机制对于代码编写者和编译器都是 透明 的存在,是从物理层面上对程序进行的优化。

7.1.3 cache 映射策略

分为「直接映射、全相联映射和组相联映射」三种。

直接映射。可以理解为模映射,即对内存块编号后模上 cache 的槽数进行映射。这种策略一个最显著的优势就是实现简单并且容易命中,但是越简单的东西缺陷也一定越明显,这种策略很可能造成极端情况发生,即某些被频繁访问的内存块对应到 cache 的行是一致的,这就无法利用上别的闲置的行,并且会导致 cache 对应的行被频繁的替换而且还不能很好的起到缓存作用。

缓存接收到 CPU 发送的地址 A 后将其解析为 3 部分,从高位到低位分别为:行标记、块群值、块内地址。其实前两个部分就直接对应了内存中的哪一块,只不过在模映射规则下,可以先通过「块群值」的比较结果来判断是否需要进行后续的判断。显然的如果此时块群匹配了,如果 cache 中的行标记和 A 的高相同位相等并且 cache 的这一行为有效行,那就算命中了,直接返回 cache 中存储的块内地址即可;反之如果 cache 中的行标记和 A 的高相同位不相等后者 cache 的这一行为不是有效行,那就算脱靶,于是就到内存找这个地址并且把这个地址在内存中的块复制到 cache 对应的行即可。

其实说白了就是牺牲一部分存储维护一个区间信息来降低时间复杂度罢了。

7.2 内存

内存采用的随机存取存储器是 DRAM,即动态随机存取存储器。相比于 SRAM,DRAM 速度较慢但容量更大,因此适合做内存工作。DRAM 存储高低电平的方式是电平,即通过电容的高低电平状态来存储 01 状态。由于 DRAM 的读操作会对高电平进行放电,因此需要定时对 DRAM 的电容进行充电工作,也就是所谓的刷新。

7.2.1 CPU 与内存进行数据交互的逻辑

基本框图

CPU 首先通过「控制线」将读/写信号送到内存,然后分读写两种情况:

  1. CPU 需要读数据时。就将需要读取数据的地址通过「地址线」送到内存寻找相应的数据,内存通过「数据线」将数据返回给 CPU。
  2. CPU 需要写数据时。就将需要写入数据的地址通过「地址线」送到内存寻找待写入的内存,同时将需要写入的数据通过「数据线」送到内存并写入指定的内存中。

CPU 与 内存的数据的通信分为异步和同步两种:

  1. 异步。CPU 从 数据线取数时需要收到内存已经准备好数据的信号。
  2. 同步。CPU 从数据线取数无须内存的信号,而是在向内存发出取数信号后,经过确定的时间后就从数据线取数。
7.2.2 地址译码器的工作逻辑

一维单译码器 VS 二维双译码器

如果有 nn 位地址位,就决定了寻址范围为 [0,2n1][0,2^n-1]。不难发现如果是一维单译码,就需要译码器有 2n2^n 个寻址线。如果是二维双译码,就需要译码器有 2×2n/22 \times 2^{n/2} 个寻址线。显然当地址位较多时,二维双译码器是更优的。

7.2.3 字/位扩展

扩展分为「字扩展、位扩展和字位同时扩展」三种。扩展逻辑已经在数电中学习过了,具体见 https://blog.dwj601.cn/GPA/3rd-term/DigitalLogicCircuit/#4-4-2-译码器-数据分配器

7.2.4 提速策略

行缓存、多模块存储器、cache (ch7.1)。

7.3 外存

7.4 虚存

7.5 存储器的数据校验

数据在信道传送的过程中,可能会因为各种噪声或者硬件原因出现错误,我们有必要进行数据的检错与纠错。本目我们主要介绍「数据检错中的奇偶校验法」策略。其实逻辑很简单,先看数据校验的流程图:

数据校验流程图

如上图所示,校验的根本逻辑就是:比对「信源和信宿数据的二进制码」在某种转换规则下的转换代码。而所谓的奇偶校验就是定义了「某种规则」为「比对数据的二进制码中 1 的个数」。即通过判断校验单元(校验位+原始数据)和传输后数据中 1 的个数的奇偶性是否相同,来判断数据传送是否正确,进而达到检错的功能。有两个问题:

  1. 这合理吗?显然不具备绝对正确性。因为奇偶校验的根本逻辑是比对数据传输前后的 1 的数量的奇偶是否。反例有很多:
    • 首先我们不能保证校验码传输前后是否会发生错误。
    • 其次针对校验逻辑,奇偶校验都不能检测出偶数位数据传输错误,并且,如果校验位前后不同则表明数据传输的过程中一定发生了错误,但是不能知道多少位 (1,3,5,...2n1)(1,3,5,...2n-1) 发生了错误,并且也不知道哪几位发生了错误。
  2. 怎么计算二进制码中 1 的个数的奇偶性?很简单,直接把所有的位异或起来即可。

码距是什么?所谓码距就是两个二进制代码(码字)中不同位的个数。在奇偶校验的码制下,两个合法码字的最小距离显然是 2。

参考:什么是奇偶校验原理?奇校验、偶校验、校验位(单比特奇偶校验、两维奇偶校验(矩阵校验或交叉奇偶校验))

设备

8 系统互连及输入输出组织

并行 *

9 并行处理系统 *

不做要求,略。

]]> + 计算机组成原理

前言

学科地位:

主讲教师学分配额学科类别
闫文珠3.5自发课

成绩组成:

作业+实验期末
50%50%

教材情况:

课程名称选用教材版次作者出版社ISBN 号
计算机组成原理《计算机组成与系统结构》3袁春风清华大学出版社978-7-302-59988-3

主板(Motherboard)

  • 作用:主板是所有硬件的连接平台,它连接并协调 CPU、内存、存储设备、显卡、网络设备和其他外部接口。主板上还包含芯片组,负责管理数据传输和设备间的通信。
  • 与其他部分的关系:主板是各个硬件组件的核心枢纽,确保数据能够在各个组件之间高效传输和通信。

机箱视角中的主板

主板的 6 大区域

输入/输出设备(I/O Devices)

  • 作用:输入设备(如键盘、鼠标、触摸屏)用于用户与计算机的交互。输出设备(如显示器、打印机)用于显示计算机的操作结果。网络设备(如网卡、Wi-Fi 模块)则允许电脑连接到网络。
  • 与其他部分的关系:输入设备通过主板将用户的操作传递给 CPU 进行处理,输出设备则从 CPU 或显卡接收处理后的数据进行展示。

I/O 区域

中央处理器(CPU)

  • 作用:CPU 是电脑的大脑,负责执行计算任务和指令。它从内存中读取指令,进行运算,然后将结果写回内存。现代 CPU 具有多个核心,可以并行处理多项任务,提高计算效率。
  • 与其他部分的关系:CPU 与内存和存储设备密切配合。它从内存中获取数据和指令,然后进行处理,最终将结果写回内存或存储设备。

CPU 区域

内存(RAM)

  • 作用:内存是计算机中的临时存储器,用于存储当前运行的程序和数据。它的访问速度极快,但数据在断电后会丢失。内存用于支持 CPU 的高速操作。
  • 与其他部分的关系:内存是 CPU 执行任务时的工作区域,存储从存储设备(如硬盘)中读取的数据和指令。内存的数据会在需要时被写回到存储设备中。

内存区域

显卡(GPU)

  • 作用:显卡负责处理图形计算任务,特别是在图像和视频渲染、游戏以及某些并行计算任务中(如深度学习)。高性能显卡能够加速这些计算任务。
  • 与其他部分的关系:显卡通过主板与 CPU 和内存通信,从内存中读取图形数据,并进行处理,然后将渲染结果输出到显示器。

存储设备(Storage Devices)

  • 作用:存储设备如机械硬盘 hard disk (HDD)、固态硬盘 solid state disk (SSD),用于长期保存数据和程序,即使在电脑断电后数据也不会丢失。SSD 的速度比传统硬盘更快,因此越来越常用。
  • 与其他部分的关系:存储设备保存操作系统、应用程序和用户数据。程序执行时,数据会从存储设备加载到内存中供 CPU 使用。

网卡(Network Interface Card, NIC)

  • 作用:网卡是计算机与网络之间的桥梁,负责处理计算机与网络间的数据通信。它将计算机的数据转换为网络信号,以太网网卡通过有线或无线方式连接局域网(LAN)或互联网。
  • 与其他部分的关系:网卡通过主板与南桥芯片组或 PCIe 总线连接,接收来自 CPU 的数据,经过处理后通过网络接口发送到外部网络。同时,它也接收来自网络的数据,传递给 CPU 进行处理,确保计算机能够与其他网络设备进行通信。

扩展区域

南桥芯片组(Southbridge)

  • 作用:南桥芯片组是主板上的一个重要芯片,为了避免过多的模块与 CPU 直连导致主板排线困难以及布局问题,产生了南桥芯片组用于负责管理较慢的外围设备的连接和数据传输。包括 USB 接口、SATA 接口、网络接口、音频设备和其他 I/O 设备。南桥还处理 BIOS、系统时钟、硬盘和光驱的控制。
  • 与其他部分的关系:南桥通过主板与 CPU 和北桥芯片组(或直接与 CPU)连接,接收来自 CPU 的指令,并将其转化为外设可以理解的信号。它还通过 I/O 总线连接各种外设,确保数据在外围设备与系统之间顺畅传输。

南桥芯片组区域

BIOS(Basic Input/Output System)

  • 作用:BIOS 是主板的固件程序,它存储在主板的 BIOS 芯片里。负责在计算机启动时进行硬件初始化,指导所有的硬件运行,并启动操作系统。
  • 与其他部分的关系:BIOS 在开机时检查并初始化硬件,然后引导操作系统,从而开始系统的工作。

BIOS 芯片

为什么要学这门课?

上上学期学习了《数字逻辑电路》,云里雾里;上学期学习了《计算机系统基础》,继续云里雾里。本学期开始学习《计算机组成原理》?如果用一句话来概括我对数字逻辑电路的理解,大概可以这样说:原来计算机是一个由“门电路”、“导线”和“时钟脉冲”组成的机器。如果用一句话来概括我对计算机系统基础的理解,大概可以这样说:原来计算机所有的活动都是由翻译过来的 01 序列驱动的。

现在我们让 AI 模仿上面我对课程理解的概括逻辑,用一句话来概括计算机组成原理,ta 是这样回答的:原来计算机的高效运作是通过硬件架构的精妙设计,将复杂的指令和数据流转化为有序的电子信号运动来实现的

会收获什么?

对计算机整体有一个宏观的把握,了解 CPU、存储和 I/O 设备的工作逻辑,尤其要熟悉单核 CPU 的工作逻辑。最后能够利用 verilog 硬件描述语言从逻辑上实现一个单核 CPU。

注:

本书 ISA 采用 MIPS 指令系统。之前学习的 《计算机系统基础》 中 ISA 采用的是 IA-32 指令系统,也就是大名鼎鼎的 x86-64 指令系统的前身。

为了形式化地描述 CPU 的运行逻辑,需要使用寄存器传送级 (register transfer level, 简称 RTL) 语言。本书 RTL 语言有以下规定:R [r] 表示通用寄存器 r 的内容,M [addr] 表示存储单元 addr 的内容;M [R[r]] 表示寄存器 r 的内容所指存储单元的内容;PC 表示 PC 的内容,M [PC] 表示 PC 所指存储单元的内容;SEXT [imm] 表示对 imm 进行符号扩展,ZEXT [imm] 表示对 imm 进行零扩展;传送方向用 \leftarrow 表示,即传送源在右,传送目的在左。

绪论

1 计算机系统概述

注:本章与《计算机系统基础》的第 1 章重复,详见 https://blog.dwj601.cn/GPA/4th-term/SysBasic/#第1章-计算机系统概述

主要掌握 冯诺依曼状态机计算机性能度量 两个知识点。

2 数据的机器级表示

注:本章与《计算机系统基础》的第 2 章重复,详见 https://blog.dwj601.cn/GPA/4th-term/SysBasic/#第2章-数据的机器级表示与处理

主要掌握 数值/非数值数据的表示数据宽度存储对齐纠/检错 四个知识点。

数据的纠错/检错见 7.4 节。

CPU

3 运算方法和运算部件

本章我们讲讲 CPU 中的 算数逻辑单元(Arithmetic and Logic Unit,简称 ALU)。ALU 在计算机中的地位大致如下图所示:

graph RL    实现功能    执行程序    机器指令    subgraph ALU    direction RL    算数运算逻辑    subgraph 运算部件    direction LR    加法器    移位器    end    end        运算部件 -->|硬件支持| 算数运算逻辑    ALU --> 机器指令    机器指令 --> 执行程序    执行程序 --> 实现功能

也就有了这样的学习路线:涉及运算的机器指令(MIPS 指令系统) \to 运算部件支持(逻辑电路图) \to 算数运算逻辑(算法设计)

3.1 涉及运算的机器指令

都有哪些涉及运算的机器指令?机器指令的种类有很多,我们在计算机系统基础中已经学习到了比如:数据传输指令、控制指令、跳转指令等等。由于本章讲解的是算数运算,因此我们主要学习涉及到运算的机器指令。我们将所有涉及到运算的机器指令抽丝剥茧,凝练出了以下共 11 条指令:

11 条涉及运算指令的 RTL 描述

11 条涉及运算指令的 RTL 描述 - 续

不难发现,上述 11 种除了最后一个跳转指令,其余的 10 条运算指令只有 3 大类:加减法、按位或、小于置 1。

3.2 运算部件支持

有了上述的程序运算需求,如何设计出对应的硬件来支持呢?CPU 中的 ALU 单元就是负责运算的部件。ALU 内部的逻辑结构如下图所示,其输入有两个运算数 A 和 B 以及一个控制输入 ALUctr:

ALU 实现

ALUctr 有 4 个控制信号,下面借书中原文分别解释 4 个控制信号对应的逻辑:

SUBctr 用来控制 ALU 执行加法还是减法运算。当 SUBctr = 1 时,做减法;当 SUBctr = 0 时,做加法。

OPctr 用来控制选择哪种运算的结果作为 Result 输出。因为所实现的 11 条指令中只可能有加/减、按位或、小于置 1 这 3 大类运算,所以 OPctr 有两位。

OVctr 用来控制是否要进行溢出判断。当 OVctr = 1 时,进行溢出判断,此时,若结果发生溢出,则溢出标志 Overflow 为 1,当 OVctr = 0 时,无须溢出判断,此时,即使结果发生溢出,溢出标志 Overflow 也不为 1。

SIGctr 信号控制 ALU 是执行「带符号整数比较小于置 1」还是执行「无符号数比较小于置 1」的功能。当 SIGctr = 0,执行「无符号数比较小于置 1」的功能;当 SIGctr = 1 时,执行「带符号整数比较小于置 1」的功能。

完美实现了所需的 3 大类运算逻辑。

ALU 是如何控制以精准实现上述 11 条指令的?本质上就是通过 ALUctr 来实现运算控制的。除了最后一个跳转,难道 10 个指令就一定需要 log2(10)=4\lceil \log_2(10) \rceil =4 位进行信号选择控制吗?并不是,其中一些虽然功能不同,但是运算逻辑相同:

  • 指令 addiu、lw、sw 和 beq 转移目标地址计算的 ALU 控制信号取值一样,都是进行加法运算并不判溢出,记为 addu 操作.
  • 指令 subu 和 beq 判 0 操作的 ALU 控制信号可看成一样,都做减法运算并不判溢出,记为 subu 操作。

下表详细解释了每一条指令的运算类型与 ALUctr 取值之间的关系:

运算类型与 ALUctr 取值之间的关系

因此,这 11 条指令可以归纳为 7 种操作:addu、add、or、subu、sub、sltu、slt,也就是说 ALUctr 只需要 log2(7)=3\lceil \log_2(7) \rceil =3 个选择控制位!如下表所示,列出了 ALUctr 的选择控制逻辑:

ALUctr 的选择控制逻辑

为什么可以这样控制

加减运算控制(SUBctr):很显然,加法(SUBctr = 0),减法(SUBctr = 1)。

输出内容控制(OPctr):很显然, 3 大类运算就对应 3 个取值。

溢出判断控制(OVctr):很显然,有符号运算需要溢出判断,其他运算都不需要。

小于置一控制(SIGctr):这个逻辑比较有意思。为什么无符号数比大小时 Less = Carry ^ SUBctr;有符号数比大小时 Less = Sign ^ Overflow 呢?首先我们知道,比大小的本质是使用加法器做减法运算,那么 AB=A+B=A+(B+1)A-B=A+B_{\text{补}}=A+(\sim B +1)

  • 对于无符号数的比大小逻辑。由于是减法,SUBctr 一定是 1,因此此时的输出其实可以进一步归纳为 Less = Carry ^ 1 = !Carry。也就是说我们只需要分析加法运算的进位结果即可,而这就是之前学过的无符号整数加减运算逻辑了。如果 A 严格小于 B,则 B 的补码与 A 相加后不会产生进位,此时 !Carry = !0 = 1 表示 A<BA<B;如果 ABA\ge B,则 B 的补码与 A 相加后就会超过无符号整数表示的范围产生进位,此时 !Carry = !1 = 0 表示 ABA \ge B。很巧妙的逻辑。
  • 对于有符号数的比大小逻辑。
    • 如果运算没有溢出,即 Overflow=0,此时 A 与 B 的正负一定相同。A 与 B 的比大小结果可以直接根据加法器运算结果的符号位来确定。如果运算结果是负的,即 Sign=1,那么显然 A<BA<B;反之如果运算结果是正的,即 Sign=0,那么显然 ABA\ge B
    • 如果运算发生溢出,即 Overflow=1,此时 A 与 B 的正负一定不同。但我们不知道谁正谁负,根据有符号整数加减运算的溢出符号判定逻辑可知:
      • 若 A 为正数 B 为负数。溢出发生时运算结果一定是负数(正溢出),即 Sign=1,此时 Less = Sign ^ OverFlow = 0,即 ABA \ge B
      • 若 A 为负数 B 为正数,溢出发生时运算结果一定是正数(负溢出),即 Sign=0,此时 Less = Sign ^ OverFlow = 1,即 A<BA< B

3.3 算数运算逻辑

程序的运算需求有了(算数运算),能进行基本运算的硬件也设计出来了(ALU)。如何利用已有的运算部件来巧妙地设计算法以高效地实现我们常见的数学运算呢?让我们一探究竟!

3.3.1 整数加减运算

不重复造轮子,见上学期记的笔记:https://blog.dwj601.cn/GPA/4th-term/SysBasic/#2-7-4-整数加减运算

3.3.2 原码乘法运算

怎么算的?按照手算乘法的逻辑进行运算。但是在计算机内部进行了一定的改进,有三点:

  1. 并不是全部算完每一步的乘法结果后再一次性累加,而是使用一个 “局部变量” 保存前缀和(部分积);
  2. 由于每一位的乘法结果都是在最高位进行的,因此我们不是对当前的乘法运算结果左移一位,而是将前面计算出的前缀和右移一位;
  3. 由于单步乘法运算时只有 0 和 1,显然若当前为 0 则对答案的贡献也为 0,因此当乘法位为 0 时只需要前缀和右移一位即可,而不需要执行相加操作。

其实算法过程很简单,就是模拟了乘法运算的过程,这里就不罗列了。只不过其中有一些关于运算部件的巧妙利用。比如将每次部分积右移后多出来的一位存放到 Y 中,反正 Y 右移后的最后一位已经没用并且舍弃掉了,前面空出来的一位正好就用来存储部分积的最后一位。

我们将二进制位从低到高的下标从 1 开始计数,进位位记作 C,部分积记作 P,乘数位记作 Y,则有这样的递推式:

Pi=(Pi1+Xyi)1,P0=0P_i = (P_{i-1}+Xy_i) \gg 1,\quad P_0 = 0

模拟过程如下:

算例

有哪些应用场景?浮点数尾数运算。在浮点数的尾数运算中,对数值位直接使用原码乘法即可,符号位就是两个乘数的符号位相异或的结果,例如:设 [x]=0.1110[x]_\text{原}=0.1110[y]=1.1101[y]_\text{原}=1.1101,计算 [x×y][x\times y]_\text{原},符号位为 01=10\oplus 1=1,数值位为 [x]×[y][x]_\text{原} \times [y]_\text{原},即 1110×11011110 \times 1101 的原码乘法运算结果 1011011010110110

可以优化吗?分块思想。由于是逐位运算,因此我们需要进行 nn 次相乘再相加的操作,时间复杂度为 O(n)O(n)。现有的优化方案就是逐 kk 位运算,那么时间复杂度就可以优化为 O(nk)O(\frac{n}{k})

3.3.3 补码乘法运算

怎么算的?如何在已知 [X][X]_{\text{补}}[Y][Y]_{\text{补}} 的情况下,计算 [X×Y][X\times Y]_{\text{补}}?由于 [X×Y][X]×[Y][X\times Y]_{\text{补}} \ne [X]_{\text{补}} \times [Y]_{\text{补}},因此补码乘法不能直接使用原码乘法的算法,需要我们重新设计运算方法,这里引入 Booth 算法。

布斯算法的本质是将符号位与数值位一起运算,也就是对有符号数的一次性运算算法。如下推导:

推导

进而可以得到关于「真值」的部分积递推公式:

Pi=[Pi1+(yi2yi1)X]1P_{i} = [P_{i-1} + (y_{i-2}-y_{i-1})X] \gg 1

于是可以得到关于「补码」的部分积递推公式:

[Pi]=[Pi1+(yi2yi1)X]1=[Pi1]1+[(yi2yi1)X]1\begin{aligned}[P_{i}] _{\text{补}} &= [P_{i-1} + (y_{i-2}-y_{i-1})X]_{\text{补}} \gg 1 \\&= [P_{i-1}] _{\text{补}} \gg 1 + [(y_{i-2}-y_{i-1})X]_{\text{补}} \gg 1\end{aligned}

显然的 yi2yi1y_{i-2}-y_{i-1} 只有 1,0,1-1,0,1 共 3 种情况,因此我们只需要知道 [X]-[X]_{\text{补}}[X][X]_{\text{补}} 即可利用移位和加法快速运算。例如下面的算例:

算例

有哪些应用场景?很多。由于计算机中的数据都是以补码形式存储,因此补码乘法的使用场景更加广泛。

可以优化吗?分块思想。与原码乘法的优化方案类似。

4 指令系统

5 中央处理器

6 指令流水线

存储

7 存储器

当机器有了记忆,就可以不用人为干预而自动运行,从而真正意义上实现了自动化的流程。本章主要学习计算机中的存储器件、存储策略以及数据交互逻辑。

现代计算机中有很多类型的存储器,核心功能都是存储数据,那为什么不统一成一种存储器呢?根本原因是 CPU 的计算速度远大于从存储器中访存数据的速度,因此我们不得不设计出可以匹配 CPU 计算速度的存储器结构。但这样的存储器造价极高并且存储量很小,因此我们只能退而求其次,从而诞生了现代计算机中的层次存储结构。从 CPU 开始依次为:寄存器 (Register)缓存 (Cache)内存 (Main Memory)外存 (Secondary Memory)。这些存储器的访存速度逐渐降低。本章也将按照这样的顺序分别讲解相应的概念。最后会补充介绍一下存储器中的 虚拟存储 理念以及 数据校验 策略。

7.1 缓存

缓存采用的随机存取存储器是 SRAM,即静态随机存取存储器。由于其速度快容量小且造价很高,因此适合做 cache 工作。SRAM 存储高低电平的方式是触发器。

7.1.1 为什么会有缓存

为什么会有缓存?这是基于什么特性才产生的?程序的局部性。

程序为什么具有局部性?从高级语言的逻辑进行理解不难发现,数据的重复访问往往集中在一个程序段中,而对应的指令和数据是连续存储的,因此程序在内存中运行时具有局部性。不断执行的某些指令和不断访问的某些数据就可以存入缓存中。这里根据局部性再衍生出「空间局部性」和「时间局部性」两个概念。

  • 空间局部性简单来讲就是某些指令或数据「存储顺序和访问顺序的差异」。差异越小,空间局部性就越好。
  • 时间局部性简单来讲就是某些指令或数据「短时间内重复访问的情况」。重复访问的越多,时间局部性就越好。
7.1.2 工作逻辑

分块思想。cache 一般被组装在 CPU 内部,便于和寄存器进行高效的数据交互。与此同时,设计者将 cache 和内存进行了划分,使得两者被划分为由相同大小存储空间组成的存储器,在 cache 中被称为行 (line) 或槽 (slot),在内存中被称为块 (block)。为了知道 cache 中每一个槽中是否有有效缓存,设计者对 cache 中的每一个槽设定了一个有效位来区分是否存储了有效缓存数据。下图给出了 CPU 读取内存信息的流程:

CPU 读取内存信息的流程

示例解释。有了上述对 cache 和内存「分块」的思想,也就可以解释从大一一开始就提到的,下面两个程序执行时间天壤之别的原因:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int a[M][N];

// 更快
int s = 0;
for (int i = 0; i < M; i++) {
for (int j = 0; j < N; j++) {
s += A[i][j];
}
}

// 很慢
int t = 0;
for (int j = 0; j < N; j++) {
for (int i = 0; i < M; i++) {
s += A[i][j];
}
}

我们知道上述两个程序段的指令数量很少,就忽略指令的局部性带来的差异,仅仅考虑数据的局部性差异。由于对数据进行访问时,不同的数据仅仅是地址的差异,而每一个数据的地址都是用加法器一步运算出来的,按道理并不会有任何时间开销的差异。那这几十倍的时间开销差异从何而来?其实就是缓存的功劳。

值得一提的是。如果数据/指令的大小比一个「槽」或「块」还要小,那就利用不上缓存机制了。

缓存是物理存在但逻辑透明的。从上述讨论可以发现,缓存机制对于代码编写者和编译器都是 透明 的存在,是从物理层面上对程序进行的优化。

缓存 - 内存平均访问时间。我们定义「缓存 - 内存平均访问时间」为 P×Tc+(1P)×(Tc+Tm)P \times T_c + (1 - P) \times (T_c + T_m)。其中 PP 表示命中的概率,TcT_c 表示访问缓存的平均时间,TmT_m 表示访问内存的平均时间。

7.1.3 映射策略

cache 和内存的映射关系是什么?接下来我们深入探讨「直接映射、全相联映射和组相联映射」三种映射策略。

直接映射

分块示意图 | 直接映射

访存机制。地址译码器接收到 CPU 发送的地址后将其解析为 3 部分,如上图所示从高位到低位分别为:标记、cache 行号、块内地址。前两个部分其实就直接对应了内存中的哪一块,只不过在模映射规则下,可以先通过「cache 行号」的比较结果来判断是否需要继续进行标记匹配。具体的,如果此时 cache 行号匹配成功 && 标记匹配成功 && cache 的这一行是有效行,那就算命中了,此时地址译码器就直接返回 cache 中当前地址 A 存储的数据/指令;反之如果 cache 行号匹配失败 || 标记匹配失败 || cache 的这一行不是有效行,那就算脱靶,此时就需要到内存找这个地址对应的数据/地址并返回,同时需要把这个地址在所在的块复制一份到 cache 对应的行。

特点。可以理解为模映射,即对内存块编号后模上 cache 的行数进行映射。这种策略的 优点 在于可以在一定程度上减少地址的匹配位数。但是越简单的东西 缺点 也一定越明显,这种策略很可能造成极端情况发生,即某些被频繁访问的内存块对应到 cache 的行是一致的,这就无法利用上别的闲置的行,并且会导致 cache 对应的行被频繁的替换而且还不能很好的起到缓存作用。

全相联映射

分块示意图 | 全相联映射

访存机制。存储的信息是完全一样的,此时的地址被划分为了 2 个部分,从高位到低位分别为:标记和块内地址。地址译码器根据 CPU 发出来的地址进行解析之后,遍历 cache 中所有的行进行标记匹配。

特点。内存中的每一个块可以缓存到任意一个 cache 行中。显然的全相联映射的 优点 在于直接解决了模映射策略中不断遇到模数相等需要不断替换的极端情况。缺点 在于丢失了模映射的匹配优势,每一次匹配都需要完全匹配所有的标记。

组相联映射

分块示意图 | 组相联映射

访存机制。以 N 路组相联为例,其表示 cache 中每一个组含有 N 个 cache 行,组间进行直接映射,组内全相联映射。地址译码器接收到 CPU 发送的地址后将其解析为 3 部分,从高位到低位分别为:标记、cache 组号和块内地址。首先 O(1)O(1) 计算出匹配的 cache 组号,然后遍历当前组中所有的行进行标记匹配和标志位判断。

特点。综合了直接映射和全相联映射的 优点,既可以避免频繁替换的极端情况,也可以加快地址的匹配速度。

7.1.4 替换算法

不难发现,在上述三种映射策略中,除了模映射是一一对应直接替换,另外两种映射策略由于存在随机性,需要考虑替换哪一行。我们介绍先进先出 (FIFO) 和 最近最少使用 (LRU) 两种替换算法。

先进先出 (First In First Out, FIFO)

算法比较简单,就是一个队列算法。当然实际技巧是,每次标记队头缓存行,一旦脱靶就替换队头对应的缓存行,然后将下一行标记为队头即可。

最近最少使用 (Least Recently Used)

算法也比较简单。实现技巧在于给每一个 cache 行一个计数器,从而找到最近最久未被使用的 cache 行进行替换。

问:假定计算机系统有一个容量为 32K×1632K\times 16 位的主存,主存按字节编址,每字节 1616 位。且有一个数据区容量为 4K4K 字节的 44 路组相联 Cache,采用 LRU 替换算法。主存和 Cache 之间数据交换块的大小为 6464 字。假定 Cache 开始为空,处理器顺序地从存储单元 0,1,,43510,1,\cdots,4351 中取数,一共重复 1010 次。设 Cache 比主存快 1010 倍。试分析 Cache 的结构和主存地址的划分,并说明采用 Cache 后速度提高了多少?

答:

  • Cache 的结构。由于 cache 的数据区容量为 4K,根据传输的数据块大小 64 字可以很容易算出 cache 一共有 4K/64 = 64 行。由于是 4 路组相联,因此 cache 一共有 16 组,每组 4 行。

  • 主存地址的划分。根据主存总容量 32K,以及传输的数据块大小 64 字可以很容易算出主存一共有 32K/64 = 512 块。每一个块对应到 cache 16 个组中的一个,因此主存结构如下:

    标记cache 组号块内地址
    5 位4 位6 位
  • 速度提升。由于取数是对应 [0,4151][0,4151]4152/64=684152/64=68 块,因此本质上只访问了内存中的 6868 块数据。初始的 6464 块数据需要全部访问内存获取,最后 44 块由于需要替换也需要从内存获取。因此第一轮需要访问内存 6868 次。不难发现,在 LRU 算法下,后续的 99 轮都是在对前四组中的 cache 行替换 55 次,因此后续的 99 轮每一轮都需要从内存访问 2020 次。那么命中率 PP 为:

    P=435206820×94352099.43%P=\frac{43520-68-20\times 9}{43520}\approx99.43\%

    速度提升的倍数为:

    TmP×Tc+(1P)Tm=1099.43%×1+(199.43%)×109.51\frac{T_m}{P\times T_c + (1-P)T_m} = \frac{10}{99.43\% \times 1 + (1-99.43\%)\times 10}\approx 9.51

7.2 内存

内存采用的随机存取存储器是 DRAM,即动态随机存取存储器。相比于 SRAM,DRAM 速度较慢但容量更大,因此适合做内存工作。DRAM 存储高低电平的方式是电平,即通过电容的高低电平状态来存储 01 状态。由于 DRAM 的读操作会对高电平进行放电,因此需要定时对 DRAM 的电容进行充电工作,也就是所谓的刷新。

7.2.1 CPU 与内存交互数据的逻辑

基本框图

CPU 首先通过「控制线」将读/写信号送到内存,然后分读写两种情况:

  1. CPU 需要读数据时。就将需要读取数据的地址通过「地址线」送到内存寻找相应的数据,内存通过「数据线」将数据返回给 CPU。
  2. CPU 需要写数据时。就将需要写入数据的地址通过「地址线」送到内存寻找待写入的内存,同时将需要写入的数据通过「数据线」送到内存并写入指定的内存中。

CPU 与 内存的数据的通信分为异步和同步两种:

  1. 异步。CPU 从 数据线取数时需要收到内存已经准备好数据的信号。
  2. 同步。CPU 从数据线取数无须内存的信号,而是在向内存发出取数信号后,经过确定的时间后就从数据线取数。
7.2.2 地址译码器的工作逻辑

一维单译码器 VS 二维双译码器

如果有 nn 位地址位,就决定了寻址范围为 [0,2n1][0,2^n-1]。不难发现如果是一维单译码,就需要译码器有 2n2^n 个寻址线。如果是二维双译码,就需要译码器有 2×2n/22 \times 2^{n/2} 个寻址线。显然当地址位较多时,二维双译码器是更优的。

7.2.3 字/位扩展

扩展分为「字扩展、位扩展和字位同时扩展」三种。扩展逻辑已经在数电中学习过了,具体见 https://blog.dwj601.cn/GPA/3rd-term/DigitalLogicCircuit/#4-4-2-译码器-数据分配器

7.2.4 提速策略

多模块存储器、cache (ch7.1)。

7.3 外存

7.4 虚存

7.5 数据校验

数据在信道传送的过程中,可能会因为各种噪声或者硬件原因出现错误,我们有必要进行数据的检错与纠错。本目我们主要介绍数据检错中的「奇偶校验法」策略。其实逻辑很简单,先看数据校验的流程图:

数据校验流程图

如上图所示,校验的根本逻辑就是:比对「信源和信宿数据的二进制码」在某种转换规则下的转换代码。而所谓的奇偶校验就是定义了「某种规则」为「比对数据的二进制码中 1 的个数」。即通过判断校验单元(校验位+原始数据)和传输后数据中 1 的个数的奇偶性是否相同,来判断数据传送是否正确,进而达到检错的功能。有两个问题:

  1. 这合理吗?显然不具备绝对正确性。因为奇偶校验的根本逻辑是比对数据传输前后的 1 的数量的奇偶是否。反例有很多:
    • 首先我们不能保证校验码传输前后是否会发生错误。
    • 其次针对校验逻辑,奇偶校验都不能检测出偶数位数据传输错误,并且,如果校验位前后不同则表明数据传输的过程中一定发生了错误,但是不能知道多少位 (1,3,5,...2n1)(1,3,5,...2n-1) 发生了错误,并且也不知道哪几位发生了错误。
  2. 怎么计算二进制码中 1 的个数的奇偶性?很简单,直接把所有的位异或起来即可。

码距是什么?所谓码距就是两个二进制代码(码字)中不同位的个数。在奇偶校验的码制下,两个合法码字的最小距离显然是 2。

参考:什么是奇偶校验原理?奇校验、偶校验、校验位(单比特奇偶校验、两维奇偶校验(矩阵校验或交叉奇偶校验))

设备

8 系统互连及输入输出组织

并行 *

9 并行处理系统 *

不做要求,略。

]]> @@ -535,7 +535,7 @@ /GPA/5th-term/NeuralNetworkAndDeepLearning/ - 神经网络与深度学习

前言

学科地位:

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

成绩组成:

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

教材情况:

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

学习资源:

为什么要学这门课?

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

会收获什么?

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

1 绪论

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

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

深度学习的数据处理流程

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

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

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

机器学习流程。数据 \to 模型 \to 学习准则 \to 优化算法

线性模型。学习任务是分类、回归。学习准则有:

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

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

2 前馈神经网络

所谓的前馈神经网络,就是网络模型中的全连接层。通过向前计算数据,向后更新参数实现拟合的功能。

前馈神经网络模型的学习准则一般采用交叉熵损失,优化算法一般采用小批量随机梯度下降 (Mini-batch Stochastic Gradient Descent, 简称 Mini-batch SGD)。

2.1 神经元

网络模型中的最小学习单元是什么?神经元。每一个神经元接受输入 zz,通过设定好的激活函数 ff,给出输出 a=f(z)a=f(z)。神经元中的激活函数大致种类极多,主要有以下 3 种:

  • Sigmoid 函数。例如 logistic 函数和 Tanh 函数。
  • Relu 函数。
  • 复合函数。

激活函数的 4 个原则是什么?为什么?。非线性、可导、单调、有界。为了确保网络可以拟合复杂的映射关系,需要激活函数是非线性的;为了便于对网络求导从而进行参数更新,需要确保激活函数是可导的;为了防止对网络求导的过程中出现梯度消失或者梯度爆炸,需要确保激活函数是单调并且有界。

2.2 反向传播算法

注:我们定义输入层为前,输出层为后。

在进行随机梯度下降时。经过简单的推导可以发现,第 ll 层的损失 δ(l)\delta (l) 依赖于后一项的损失 δ(l+1)\delta(l+1),于是每一个样本更新参数的逻辑就是从输出层开始逐层往前直到第一层隐藏层进行更新。

3 卷积神经网络

4 循环神经网络

5 记忆与注意力机制

6 网络优化与正则化

7 概率图模型

8 深度生成模型

9 深度强化学习

个人大作业 *

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

前言

学科地位:

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

成绩组成:

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

教材情况:

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

学习资源:

为什么要学这门课?

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

会收获什么?

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

1 绪论

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

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

深度学习的数据处理流程

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

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

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

机器学习流程。数据 \to 模型 \to 学习准则 \to 优化算法。

线性模型。学习任务是分类、回归。学习准则有:

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

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

2 前馈神经网络

前馈神经网络 (Feedforward Neural Network) 就是网络模型中的全连接层。通过向前传递数据,向后更新参数,实现学习和拟合的功能。

前馈神经网络模型的学习准则一般采用交叉熵损失,优化算法一般采用小批量随机梯度下降 (Mini-batch Stochastic Gradient Descent, 简称 Mini-batch SGD) 算法。

2.1 神经元

网络模型中的最小学习单元是什么?神经元。每一个神经元接受输入 zz,通过设定好的激活函数 ff,给出输出 a=f(z)a=f(z)。神经元中的激活函数大致种类极多,主要有以下 3 种:

  • Sigmoid 函数。例如 logistic 函数和 Tanh 函数。
  • Relu 函数。
  • 复合函数。

激活函数的 4 个原则是什么?为什么?。非线性、可导、单调、有界。为了确保网络可以拟合复杂的映射关系,需要激活函数是非线性的;为了便于对网络求导从而进行参数更新,需要确保激活函数是可导的;为了防止对网络求导的过程中出现梯度消失或者梯度爆炸,需要确保激活函数是单调并且有界。

2.2 反向传播算法

注:我们定义输入层为前,输出层为后。

在进行随机梯度下降时。经过简单的推导可以发现,第 ll 层的损失 δ(l)\delta (l) 依赖于后一项的损失 δ(l+1)\delta(l+1),于是每一个样本更新参数的逻辑就是从输出层开始逐层往前直到第一层隐藏层进行更新。

3 卷积神经网络

4 循环神经网络

5 记忆与注意力机制

6 网络优化与正则化

7 概率图模型

8 深度生成模型

9 深度强化学习

个人大作业 *

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

前言

学科地位:

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

成绩组成:

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

教材情况:

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

学习资源:

为什么要学这门课?

数据库是时代变迁的产物,掌握数据库对应的知识和技能很有必要。这门课主要学习关系数据库的相关理论并以 openGauss 作为实验平台进行实操。可以借这门课好好熟悉关系数据库的相关技能,如果想要深入 system,db 是需要好好学习甚至深入精通的。

会收获什么?

自顶向下理解关系数据库的架构与设计模式,熟悉数据库编程对应的技能。如果还有精力,就继续深入底层架构。

1 绪论

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

数据库系统概念图:

---title: 数据库系统---graph TB    DBM[数据库管理员]    subgraph 数据    DB[(数据库\n「内模式」)]    DBMS[数据库管理系统\n「逻辑模式」]    subgraph DBAP[应用程序]    direction LR    out_model1[视图1\n「外模式1」]    out_model2[视图2\n「外模式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,) 来逻辑表示一个关系。其中 RR 表示关系,AA 表示属性,DD 表示属性的取值域。以下关键词几乎涵盖了关系的大部分术语。

关系的基本改变

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 关系代数

所有的关系运算都可以用符号来表示,符号符合对应的算律。这么表示的目的有助于在理论上对表达式进行化简,从而降低计算开销。

2.4.1 传统的集合运算

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

2.4.2 专门的关系运算

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

  • 元组。在关系 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 的属性 B」符合条件 θ\theta 的行。当关系为取等时,被称为等值连接;当关系为取等并且需要在连接结果中删除这两个相同属性中的一个时,就叫做自然连接。
  • 左外连接。当 R 的属性 A 的取值不在 S 的 B 中时,在结果中保留 R 的结果,S 对应的值填 NULL。
  • 右外连接。当 S 的属性 B 的取值不在 R 的 A 中时,在结果中保留 S 的结果,R 对应的值填 NULL。
  • 外连接。R 与 S 的都保留,另外一个不存在的都填 NULL。

除法 R÷SR \div S

除法

  • 对于两个关系 R(X,Y)R(X,Y)S(Y,Z)S(Y,Z)R÷SR\div S 的结果 P(X)P(X) 是 R 满足下列条件的元组在 X 上的投影:R 在 X 上的象集包含 S 在 Y 上的投影。

  • 有点抽象,举个例子就知道了:

    例题

    例题 - 续

3 SQL

本章我们学习 结构化查询语言 (Structured Query Language, 简称 SQL)。共有数据定义、数据控制和数据操纵三种操作类型。

在此之前,有必要知道 DMBS 的层次结构。从最开始的一个机器(例如服务器),机器中下载安装了 DBMS(例如 openGauss),DBMS 中创建了很多的数据库(例如 postgres、experiment 等等),每一个数据库中会有很多的 schema(例如默认的 public 等等),每一个 schema 中可以创建很多的表(例如 student 等等)。

练习网站:

3.1 数据定义

首先我们需要使用 create database <db_name> 创建一个数据库,然后按照层次结构分别有以下对象的作用和操作方法。

操作对象创建删除修改
模式create schemadrop schema-
create tabledrop tablealter table
视图create viewdrop view-
索引create indexdrop indexalter index

对于模式 schema。可以理解为命名空间,不同的 schema 下可以创建同名但功能和用处不同的表。

对于表 table。就是关系数据库中最基本的存储单元。

对于视图 view。TODO

对于索引 index。就是通过某种数据结构对表中的某个字段建立索引从而加速查询过程。也就是通过空间换时间的理念,离线建索引,动态查询。常见的建索引的手段包括排序、哈希、B+索引等等。显然,由于是离线操作,每一次对数据的更新都需要重新建立索引,因此对于经常修改数据的场景,就不太适合建立索引。

3.2 数据操纵

有了数据,就可以对其进行操纵。常见的有增删查改四种操作。

3.3 数据控制

4 安全性

5 完整性

6 关系数据理论

7 数据库设计

8 数据库编程 *

这一章不作考试要求。主要讲高级语言通过驱动与数据库进行交互的逻辑。

9 存储管理

10 关系查询

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

11 数据库恢复技术

12 并发

]]> + 数据库

前言

学科地位:

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

成绩组成:

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

教材情况:

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

学习资源:

为什么要学这门课?

数据库是时代变迁的产物,掌握数据库对应的知识和技能很有必要。这门课主要学习关系数据库的相关理论并以 openGauss 作为实验平台进行实操。可以借这门课好好熟悉关系数据库的相关技能,如果想要深入 system,db 是需要好好学习甚至深入精通的。

会收获什么?

自顶向下理解关系数据库的架构与设计模式,熟悉数据库编程对应的技能。如果还有精力,就继续深入底层架构。

基础理论

1 绪论

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

数据库系统概念图:

---title: 数据库系统---graph TB    DBM[数据库管理员]    subgraph 数据    DB[(数据库\n「内模式」)]    DBMS[数据库管理系统\n「逻辑模式」]    subgraph DBAP[应用程序]    direction LR    out_model1[视图1\n「外模式1」]    out_model2[视图2\n「外模式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,) 来逻辑表示一个关系。其中 RR 表示关系,AA 表示属性,DD 表示属性的取值域。以下关键词几乎涵盖了关系的大部分术语。

关系的基本概念

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 关系代数

所有的关系运算都可以用符号来表示,符号符合对应的算律。这么表示的目的有助于在理论上对表达式进行化简,从而降低计算开销。

2.4.1 传统的集合运算

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

2.4.2 专门的关系运算

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

  • 元组。在关系 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 的属性 B」符合条件 θ\theta 的行。当关系为取等时,被称为等值连接;当关系为取等并且需要在连接结果中删除这两个相同属性中的一个时,就叫做自然连接。
  • 左外连接。当 R 的属性 A 的取值不在 S 的 B 中时,在结果中保留 R 的结果,S 对应的值填 NULL。
  • 右外连接。当 S 的属性 B 的取值不在 R 的 A 中时,在结果中保留 S 的结果,R 对应的值填 NULL。
  • 外连接。R 与 S 的都保留,另外一个不存在的都填 NULL。

除法 R÷SR \div S

除法

  • 对于两个关系 R(X,Y)R(X,Y)S(Y,Z)S(Y,Z)R÷SR\div S 的结果 P(X)P(X) 是 R 满足下列条件的元组在 X 上的投影:R 在 X 上的象集包含 S 在 Y 上的投影。

例题

例题 - 续

3 SQL

本章我们学习 结构化查询语言 (Structured Query Language, 简称 SQL)。共有数据定义、数据操纵和数据控制三种操作类型。其中数据控制将在第 4 章的数据安全性和第 5 章的数据完整性分别展开。

在此之前,有必要知道 DMBS 的层次结构。从最开始的一个机器(例如服务器),机器中下载安装了 DBMS(例如 openGauss),DBMS 中创建了很多的数据库(例如 postgres、experiment 等等),每一个数据库中会有很多的 schema(例如默认的 public 等等),每一个 schema 中可以创建很多的表(例如 student 等等)。

练习网站:

3.1 数据定义

我们首先需要使用 create database <db_name> 创建一个数据库。

对于数据定义,一共有 创建 (create)、删除 (drop)、修改 (alter) 三种操作。按照定义对象的不同有下表所示的语句:

操作对象创建删除修改
模式create schemadrop schema-
create tabledrop tablealter table
视图create viewdrop view-
索引create indexdrop indexalter index

对于模式 schema。可以理解为命名空间,不同的 schema 下可以创建同名但功能和用处不同的表。

对于表 table。就是关系数据库中最基本的存储单元。

对于视图 view。这是一种抽象的说法,本质上就是通过定义特定的视图,让特定的人看到数据库中特定的数据。

对于索引 index。就是通过某种数据结构对表中的某个字段建立索引从而加速查询过程。也就是通过空间换时间的理念,离线建索引,动态查询。常见的建索引的手段包括排序、哈希、B+索引等等。显然,由于是离线操作,每一次对数据的更新都需要重新建立索引,因此对于经常修改数据的场景,就不太适合建立索引。

3.2 数据操纵

有了数据,就可以对其进行操纵。一共有 增 (insert)、删 (delete)、改 (update)、查 (select) 四种操作。

3.2.1 insert
3.2.2 delete
3.2.3 update
3.2.4 select

查询的内送

4 安全性

5 完整性

应用开发

6 关系数据理论

7 数据库设计

8 数据库编程 *

这一章不作考试要求。主要讲高级语言通过驱动与数据库进行交互的逻辑。

系统优化

9 存储管理

10 关系查询

可以怎么优化查询策略呢?

11 数据库恢复技术

12 并发

]]> @@ -909,16 +909,16 @@ - operation-guide - - /Operation/operation-guide/ + road-map-guide + + /RoadMap/road-map-guide/ - 运维指南

前言

本文旨在介绍运维的基本理念和路径规划。正在不断完善中。

云计算运维的相关知识包括但不限于:

  1. Linux 基础:常用命令、文件及用户管理、文本处理、Vim 工具使用。
  2. 网络基础:网络基础知识、TCP/IP 协议、七层网络模型、Linux 网络管理与配置。
  3. 服务器运维:SSH 远程连接、文件上传下载、Nginx 和 MySQL 服务器搭建、LVS 负载均衡配置以及服务器优化经验。
  4. 自动化运维:Shell 脚本编写、自动化运维工具 Ansible 的使用。

Linux 基础

网络基础

服务器运维

服务器 server 就是类似于一台台电脑,量产后在全球各地通过网络为用户提供高质量的便捷服务。在学习服务器相关知识之前,有必要先了解计算机网络相关的概念,有助于后续理解代理系统等的运作逻辑与开发逻辑。在正式开始之前,首先罗列一下最基本的概念:

  • 云服务器:

    • 实例:一个云服务器相当于一个抽象的类,在其中购买配置了指定的实例后相当于实例化一个类,从而一个云服务器对应一个实例
    • ip 地址:一台云服务器对应唯一的一个 ip 地址
    • 备案:所谓的网站备案其实是对云服务器进行备案。当前的形式是,对于指向中国大陆 ip 的云服务器需要备案,如果指向的是非中国大陆的 ip,就不需要备案了。一般而言,中文的指向 HK,英文的指向 UK
    • 端口:一台电脑能做的肯定不止一种,ip 地址端口的概念就好比一台电脑可以有不同的功能模块,不同的端口对应服务器不同的数据通道,开发者可以通过不同的端口开发不同的功能,用户可以通过不同的端口使用一台服务器上的不同服务
    • 宝塔面板:由于 linux 操作系统的命令行操作方式与 windows 的图形化界面操作方式差别较大,对于习惯图形化操作的用户不友好,故有一系列 图形化管理 linux 操作系统的工具,宝塔面板就是其中的一个代表
  • 域名:

    • 概念:代替 ip 地址访问的一种更加容易记忆与推广的媒介,通过 DNS 服务器将域名与 ip 地址进行绑定后,用户通过域名 domain 即可访问相应的服务器的资源
    • 顶级域名:即 域名+后缀 的组合
    • 二级域名:即 主机名+域名+后缀 的组合
  • 协议:

    • 概念:服务器与用户进行数据传输的一种约定规则
    • http:传统的数据传输协议,默认服务器 80 端口进行访问
    • https:即 http + ssl,传统协议 + ssl 加密证书,默认服务器 443 端口访问
    • ssl:即 Secret Sockets Layer 安全套件层,用于加密服务器与用户之间传输的数据。其中证书文件为 pem 文件,密钥文件为 key 文件
  • 网站框架:

    • 目前两款主流的网站框架分别为 LNMP 与 LAMP,都是用于搭建 Web 服务器环境的 软件堆栈
    • LNMP:表示使用 Linux 操作系统、Nginx 作为 Web 服务器、MySQL 作为数据库、PHP 作为服务器端脚本语言的技术堆栈
    • LAMP:表示使用 Linux 操作系统、Apache 作为 Web 服务器、MySQL 作为数据库、PHP 作为服务器端脚本语言的技术堆栈

自动化运维

参考

Linux 运维学习路线 - 阿里云

菜鸟 Linux 教程

搭建一个自己的网站?看这个就够了!

]]> + 学习路线指南

参考:https://www.codefather.cn/学习路线/

注:打星 ()(*) 内容为上述链接未涉及到的路线,侵权必删

计算机基础

计算机网络

参考:https://www.code-nav.cn/post/1640588119619551233

操作系统

参考:https://www.code-nav.cn/post/1640587909942099969

软件开发通用

企业项目研发流程 *

大纲:

大纲

内容:

内容1

内容2

Git & GitHub

个人博客:http://blog.dwj601.cn/DevTools/Git/git-learning-record/

Linux

参考:https://www.codefather.cn/linux学习路线-by-程序员鱼皮/

设计模式

参考:https://www.codefather.cn/设计模式学习路线-by-程序员鱼皮/

软件工程 *

大纲:

大纲

内容:

内容

后端开发通用

SQL

参考:https://www.codefather.cn/sql免费实战自学网站-by-程序员鱼皮/

MySQL *

大纲:

大纲

内容:

内容

Redis *

大纲:

大纲

内容:

内容

小程序 *

大纲:

大纲

内容:

内容

Python

参考:https://www.codefather.cn/python学习路线-by-程序员鱼皮/

C++

参考:https://www.codefather.cn/c-学习路线-by-程序员鱼皮/

]]> - Operation + RoadMap @@ -928,16 +928,16 @@ - road-map-guide - - /RoadMap/road-map-guide/ + operation-guide + + /Operation/operation-guide/ - 学习路线指南

参考:https://www.codefather.cn/学习路线/

注:打星 ()(*) 内容为上述链接未涉及到的路线,侵权必删

计算机基础

计算机网络

参考:https://www.code-nav.cn/post/1640588119619551233

操作系统

参考:https://www.code-nav.cn/post/1640587909942099969

软件开发通用

企业项目研发流程 *

大纲:

大纲

内容:

内容1

内容2

Git & GitHub

个人博客:http://blog.dwj601.cn/DevTools/Git/git-learning-record/

Linux

参考:https://www.codefather.cn/linux学习路线-by-程序员鱼皮/

设计模式

参考:https://www.codefather.cn/设计模式学习路线-by-程序员鱼皮/

软件工程 *

大纲:

大纲

内容:

内容

后端开发通用

SQL

参考:https://www.codefather.cn/sql免费实战自学网站-by-程序员鱼皮/

MySQL *

大纲:

大纲

内容:

内容

Redis *

大纲:

大纲

内容:

内容

小程序 *

大纲:

大纲

内容:

内容

Python

参考:https://www.codefather.cn/python学习路线-by-程序员鱼皮/

C++

参考:https://www.codefather.cn/c-学习路线-by-程序员鱼皮/

]]> + 运维指南

前言

本文旨在介绍运维的基本理念和路径规划。正在不断完善中。

云计算运维的相关知识包括但不限于:

  1. Linux 基础:常用命令、文件及用户管理、文本处理、Vim 工具使用。
  2. 网络基础:网络基础知识、TCP/IP 协议、七层网络模型、Linux 网络管理与配置。
  3. 服务器运维:SSH 远程连接、文件上传下载、Nginx 和 MySQL 服务器搭建、LVS 负载均衡配置以及服务器优化经验。
  4. 自动化运维:Shell 脚本编写、自动化运维工具 Ansible 的使用。

Linux 基础

网络基础

服务器运维

服务器 server 就是类似于一台台电脑,量产后在全球各地通过网络为用户提供高质量的便捷服务。在学习服务器相关知识之前,有必要先了解计算机网络相关的概念,有助于后续理解代理系统等的运作逻辑与开发逻辑。在正式开始之前,首先罗列一下最基本的概念:

  • 云服务器:

    • 实例:一个云服务器相当于一个抽象的类,在其中购买配置了指定的实例后相当于实例化一个类,从而一个云服务器对应一个实例
    • ip 地址:一台云服务器对应唯一的一个 ip 地址
    • 备案:所谓的网站备案其实是对云服务器进行备案。当前的形式是,对于指向中国大陆 ip 的云服务器需要备案,如果指向的是非中国大陆的 ip,就不需要备案了。一般而言,中文的指向 HK,英文的指向 UK
    • 端口:一台电脑能做的肯定不止一种,ip 地址端口的概念就好比一台电脑可以有不同的功能模块,不同的端口对应服务器不同的数据通道,开发者可以通过不同的端口开发不同的功能,用户可以通过不同的端口使用一台服务器上的不同服务
    • 宝塔面板:由于 linux 操作系统的命令行操作方式与 windows 的图形化界面操作方式差别较大,对于习惯图形化操作的用户不友好,故有一系列 图形化管理 linux 操作系统的工具,宝塔面板就是其中的一个代表
  • 域名:

    • 概念:代替 ip 地址访问的一种更加容易记忆与推广的媒介,通过 DNS 服务器将域名与 ip 地址进行绑定后,用户通过域名 domain 即可访问相应的服务器的资源
    • 顶级域名:即 域名+后缀 的组合
    • 二级域名:即 主机名+域名+后缀 的组合
  • 协议:

    • 概念:服务器与用户进行数据传输的一种约定规则
    • http:传统的数据传输协议,默认服务器 80 端口进行访问
    • https:即 http + ssl,传统协议 + ssl 加密证书,默认服务器 443 端口访问
    • ssl:即 Secret Sockets Layer 安全套件层,用于加密服务器与用户之间传输的数据。其中证书文件为 pem 文件,密钥文件为 key 文件
  • 网站框架:

    • 目前两款主流的网站框架分别为 LNMP 与 LAMP,都是用于搭建 Web 服务器环境的 软件堆栈
    • LNMP:表示使用 Linux 操作系统、Nginx 作为 Web 服务器、MySQL 作为数据库、PHP 作为服务器端脚本语言的技术堆栈
    • LAMP:表示使用 Linux 操作系统、Apache 作为 Web 服务器、MySQL 作为数据库、PHP 作为服务器端脚本语言的技术堆栈

自动化运维

参考

Linux 运维学习路线 - 阿里云

菜鸟 Linux 教程

搭建一个自己的网站?看这个就够了!

]]> - RoadMap + Operation @@ -968,11 +968,11 @@ - OptMethod - - /GPA/4th-term/OptMethod/ + MachineLearning + + /GPA/4th-term/MachineLearning/ - 最优化方法

前言

学科地位:

主讲教师学分配额学科类别
王启春4专业课

成绩组成:

平时(作业+考勤)期中(大作业)期末(闭卷)
20%30%50%

教材情况:

课程名称选用教材版次作者出版社ISBN号
最优化算法《最优化方法》2孙文瑜高等教育出版社978-7-04-029763-8

第一章 基本概念

1.1 最优化问题简介

本目主要讲解最优化问题的一些分类,下附脑图(由 Xmind 软件制作):

分类脑图

1.2 凸集和凸函数

由于本书介绍的最优化求解方法一般只适用于求解局部最优解,那么如何确定全局最优解呢?以及如何确定唯一的全局最优解呢?本目揭晓答案:

(唯一)全局最优解=局部最优解+(严格)凸目标函数+凸可行域(\text{唯一})\text{全局最优解}=\text{局部最优解}+(\text{严格})\text{凸目标函数}+\text{凸可行域}

其中凸可行域包含于凸集,凸目标函数包含于凸函数,凸目标函数+凸可行域的问题称为凸规划问题。因此本目将分别介绍凸集、凸函数和凸规划三个概念。

1.2.1 凸集

凸集的定义:若集合中任意两点的线性组合都包含于集合,则称该集合为凸集。符号化即:

x,yD  λ[0,1]s.t.λx+(1λ)yD\begin{aligned}\forall \quad x,y \in D \ \land \ \lambda \in [0,1] \\s.t. \quad \lambda x+(1-\lambda)y \in D \\\end{aligned}

凸集的性质:设 D1,D2RnD_1,D_2 \subset R^n 均为凸集(下列 x,yx,y 均表示向量),则:

  1. 两凸集的交 D1D2={x  xD1xD2}D_1 \cap D_2 = \{x\ |\ x \in D_1 \land x \in D_2\} 是凸集
  2. 两凸集的和 D1+D2={x,y  xD1,yD2}D_1 + D_2 = \{x,y\ |\ x \in D_1 , y \in D_2\} 是凸集
  3. 两凸集的差 D1D2={x,y  xD1,yD2}D_1 - D_2 = \{x,y\ |\ x \in D_1 , y \in D_2\} 是凸集
  4. 对于任意非零实数 α\alpha,集合 αD1={αx  xD1}\alpha D_1 = \{ \alpha x \ |\ x \in D_1 \}​ 是凸集

凸集的性质证明

凸集的应用

  1. 刻画可行域

    • 凸组合定义

      设 x(1),x(2),,x(p)Rn,且 i=1pαi=1(ai0)s.t.x=α1x1+α2x2++αpxp则称 x 为向量 x(1),x(2),,x(p) 的凸组合\begin{aligned}\text{设}\ x^{(1)},x^{(2)}, \cdots, x^{(p)} \in R^n ,\text{且}\ \sum_{i=1}^p \alpha_i = 1(a_i \ge 0) \\s.t. \quad x = \alpha_1x^1 + \alpha_2x^2 + \cdots + \alpha_px^p \\\text{则称}\ x\ \text{为向量}\ x^{(1)},x^{(2)}, \cdots, x^{(p)}\ \text{的凸组合}\end{aligned}

    • 凸组合定理:DRnD \in R^n 是凸集的充分必要条件是 DD 中任取 mm 个点 xi(1,2,m)x^i(1,2,\cdots m) 的凸组合任属于 DD,即:

      i=1mαixiD(αi0(i=1,2,,m),i=1mαi=1)\sum_{i=1}^m \alpha_ix_i \in D\left( \alpha_i \ge 0(i=1,2,\cdots,m),\sum_{i=1}^m \alpha_i = 1 \right)

      凸组合定理证明

  2. 分析最优解的最优性条件

    • 超平面定义(凸集分离定理):设 D1,D2RnD_1,D_2 \subset R^n 为两非空凸集,若存在非零向量 αRn\alpha \in R^n 和实数 β\beta,使得

      D1H+={xRn  αTxβ}D2H={xRn  αTxβ}\begin{aligned}D_1 \subset H^+ = \{ x \in R^n \ | \ \alpha^T x \ge \beta\} \\D_2 \subset H^- = \{ x \in R^n \ | \ \alpha^T x \le \beta\}\end{aligned}

      则称超平面 H={xRn  αTx=β}H = \{ x \in R^n \ | \ \alpha^Tx=\beta \} 分离集合 D1D_1D2D_2严格分离时上述不等式无法取等

    • 投影定理:设 DRnD \in R^n 是非空闭凸集,yRny \in R^nyDy \notin D,则

      (1)存在唯一的点 xD,使得集合D到点y的距离最小(2)xD是点y到集合D的最短距离点的充分必要条件为:xD,<xx,yx>0\begin{align*} (1)& \text{存在唯一的点} \ \overline x \in D,\text{使得集合}D\text{到点} y \text{的距离最小} \\ (2)& \overline x \in D \text{是点} y \text{到集合D的最短距离点的充分必要条件为}:\forall x \in D,<x-\overline x,y - \overline x> \le 0\end{align*}

1.2.2 凸函数

凸函数的定义:设函数 f(x)f(x) 在凸集 DD 上有定义

  • x,yD 和 λ[0,1]\forall x,y \in D\ \text{和}\ \lambda \in [0,1]f(λx+(1λ)y)λf(x)+(1λ)f(y)f(\lambda x + (1-\lambda)y) \le \lambda f(x) + (1-\lambda)f(y),则称 f(x)f(x) 是凸集 DD 上的凸函数
  • x,yD 和 λ(0,1)\forall x,y \in D\ \text{和}\ \lambda \in (0,1)f(λx+(1λ)y)<λf(x)+(1λ)f(y)f(\lambda x + (1-\lambda)y) < \lambda f(x) + (1-\lambda)f(y),则称 f(x)f(x) 是凸集 DD 上的严格凸函数

凸函数的性质:

  1. 如果 ff 是定义在凸集 DD 上的凸函数,实数 α0\alpha \ge 0,则 αf\alpha f 也是凸集 DD 上的凸函数
  2. 如果 f1,f2f_1,f_2 是定义在凸集 DD 上的凸函数,则 f1+f2f_1+f_2 也是凸集 DD 上的凸函数
  3. 如果 fi(x)(i=1,2,,m)f_i(x)(i=1,2,\cdots,m) 是非空凸集 DD 上的凸函数,则 f(x)=max1imfi(x)f(x) = \max_{1 \le i \le m} |f_i(x)| 也是凸集 DD 上的凸函数
  4. 如果 fi(x)(i=1,2,,m)f_i(x)(i=1,2,\cdots,m) 是非空凸集 DD 上的凸函数,则 f(x)=i=1mαifi(x)(αi0)f(x) = \displaystyle \sum_{i=1}^m \alpha_i f_i(x)\quad(\alpha_i \ge 0) 也是凸集 DD​ 上的凸函数

第1、2条

第4条

凸函数的判定定理

  1. 函数值角度:函数 f(x)f(x)RnR^n 上的凸函数的充分必要条件是 x,yRn\forall x,y \in R^n,单变量函数 ϕ(α)=f(x+αy)\phi(\alpha)=f(x + \alpha y) 是关于 α\alpha 的凸函数

    凸函数的判别定理证明 - 函数值角度

  2. 一阶梯度角度:设 f(x)f(x) 是定义在非空开凸集 DD 上的可微函数,则:

    • f(x)f(x)DD 上凸函数的充分必要条件是:f(y)f(x)+f(x)T(yx),x,yDf(y) \ge f(x)+\nabla f(x)^T(y-x), \quad \forall x,y \in D
    • f(x)f(x)DD 上严格凸函数的充分必要条件是:f(y)>f(x)+f(x)T(yx),x,yD,xyf(y) > f(x)+\nabla f(x)^T(y-x), \quad \forall x,y \in D,\quad x \ne y

    无需掌握证明,但是为了便于理解性记忆,可以从二次凸函数进行辅助理解记忆。
    凸函数的判别定理证明 - 一阶导数角度

  3. 二阶梯度角度:设 f(x)f(x) 是定义在非空开凸集 DD 上的二阶可微函数,则:

    • f(x)f(x)DD 上凸函数的充分必要条件是:海塞矩阵正半定
    • f(x)f(x)DD 上严格凸函数的充分必要条件是:海塞矩阵正定?其实不一定,例如 x4x^4 是严格凸函数,但是其海塞矩阵是正半定的并非正定。因此更严谨的定义应该是:海塞矩阵正定的是严格凸函数,严格凸函数的海塞矩阵是正半定的。

1.2.3 凸规划(个人补充)

本目为个人补充内容,用于整合上述 1.2.1 与 1.2.2 内容。我们知道,学习凸集和凸函数的终极目标是为了求解凸规划问题,凸规划问题可以简述为 凸可行域 + 凸目标函数 + 局部最优解 = 全局最优解,那么如何证明这个定理是正确的呢?局部最优解求出来以后,目标函数也确定为凸函数以后,如何确定可行域是凸集呢?下面揭晓:

证明凸规划问题的正确性

  1. 定理:在可行域是凸集,目标函数非严格凸的情况下,局部最优解 xx^*​ 也是全局最优解

    证明(反证法)

  2. 定理:在可行域是凸集,目标函数是严格凸的情况下,局部最优解 xx^* 也是唯一的全局最优解

    证明(反证法)

确定可行域是否为凸集

  1. 定理:若约束条件 ci(x)0c_i(x) \ge 0 中每一个约束函数 ci(x)c_i(x) 都是凹函数,则可行域 FF 是凸集

    证明

  2. 定理:若约束条件 ci(x)0c_i(x) \le 0 中每一个约束函数 ci(x)c_i(x) 都是凸函数,则可行域 FF 是凸集

    证明

  3. 定理:若约束条件中每一个约束函数 ci(x)c_i(x) 都恒等于零,则可行域 FF 是凸集

    证明

1.3 最优性条件

最优性条件是指最优化问题的最优解所必须满足的条件,本目介绍求解无约束最优化问题的一阶必要条件和二阶必要条件,再补充介绍二阶充分条件以及针对凸规划问题的二阶充分必要条件。最后简单介绍求解等式约束最优化问题的拉格朗日乘子法。

1.3.1 下降方向

在开始之前先简单介绍一下「下降方向」这个概念。

  • 下降方向定义:设 f(x)f(x) 为定义在空间 RnR^n 的连续函数,点 xˉRn\bar x \in R^n,若对于方向 sRns \in R^n 存在数 δ>\delta > 0 使

    f(xˉ+αs)<f(xˉ),α(0,δ)f(\bar x+\alpha s) < f(\bar x),\quad \forall \alpha \in (0,\delta)

    成立,则称 ssf(x)f(x)xˉ\bar x 处的一个下降方向。在 点 xˉ\bar x 处的所有下降方向的全体记为 D(xˉ)D(\bar x)

  • 下降方向定理:设函数 f(x)f(x) 在点 xˉ\bar x 处连续可微,如存在非零向量 sRns \in R^n 使

    f(xˉ)Ts<0\nabla f(\bar x)^Ts < 0

    成立,则 ssf(x)f(x) 在点 xˉ\bar x 处的一个下降方向

    证明

1.3.2 充分必要条件

f:DRnR1f:D \subset R^n \to R^1 是定义在开集 DD 上的函数,xDx^* \in D 是该函数的局部极小点,f(x)f(x) 的一阶导数 g(x)=f(x)g(x)=\nabla f(x),二阶导数 G(x)=2f(x)G(x)=\nabla ^2f(x)

  • 一阶必要条件。若目标函数在定义域 D 上连续可微,则 xx^* 是局部极小点的一阶必要条件为:

    g(x)=0g(x^*)=0

  • 二阶必要条件。若目标函数在定义域 D 上二阶连续可微,则 xx^* 是局部极小点的二阶必要条件为:

    g(x)=0,G(x)0g(x^*)=0,\quad G(x^*)\ge 0

  • 二阶充分条件。若目标函数在定义域 D 上二阶连续可微,则 xx^*严格局部极小点的二阶充分条件为:

    g(x)=0,G(x)是正定的g(x^*)=0,\quad G(x^*) \text{是正定的}

  • 充分必要条件(凸最优性定理)。在无约束最优化问题中,如果目标函数是凸的,则 xx^* 是全局极小点的充分必要条件是:

    g(x)=0g(x^*)=0

1.3.3 拉格朗日乘子法

如果现在最优化问题不是单纯的无约束最优化问题,而是增设了等式约束的等式约束最优化问题,如何求解呢?我们引入 Lagrange\text{Lagrange} 乘子法,将所有的等式约束都转化到目标函数中得到一个等价的无约束最优化问题,即:

对于这样的等式约束最优化问题:

minf(x)s.t.ci(x)=0,i=1,2,,m.\begin{aligned}\min\quad& f(x)\\\text{s.t.}\quad&c_i(x)=0,i=1,2,\cdots,m.\end{aligned}

引入拉格朗日乘子将其转化为无约束最优化问题,进而利用上述无约束最优化问题的求解策略进行求解:

L(x,λ)=f(x)i=1mλici(x)L(x,\lambda) = f(x) - \sum_{i=1}^m\lambda_ic_i(x)

1.4 最优化方法概述

现实生活中,对于常见问题的建模往往是极其复杂的,为了求得最优解,我们需要对建立的模型进行微分算子的求解。但是问题是,尽管我们知道最优解一定存在于微分算子的数值解中,但是我们往往不能很快的计算出其数值解,因此我们需要采用别的方法进行计算。最常用的方法就是本书介绍的迭代法。

大概就两步:确定初值开始判断,如果符合条件的约束则停止迭代得到最终的解;如果不符合约束则对当前迭代值赋予修正量并继续迭代判断。直到找到最终的解。接下来将对 5 个专有名词进行解释,分别为:初始点的选取、迭代点好坏的判定、收敛速度、迭代的终止条件、修正量 s(k)s^{(k)} 的确定。

1.4.1 初始点的选取

初始点的选取取决于算法的收敛性能。

  • 如果一个算法可以做到全局收敛,则初始点的选取是任意的
  • 如果一个算法只能局部收敛,则初始点的选取往往需要尽可能的接近最优解,而这往往是困难的,因为我们并不知道的最优解是什么。此时可以采用借鉴之前的经验来选择初始点

1.4.2 迭代点好坏的判定

判定一个迭代点的好坏时,我们往往会设计一个评价函数,而评价函数的设计取决于约束问题的种类。

  • 对于无约束最优化问题。我们可以直接将目标函数 f(x)f(x) 设计为评价函数。很显然,如果一个迭代点对应的的函数值比之前点对应的函数值更小,则可以说明当前迭代点由于之前的点
  • 对于有约束最优化问题。此时我们不仅仅需要考虑函数值的大小,也要考虑当前迭代点的可行程度(离可行域的距离)。因此此类最优化问题往往既包含目标函数,也包含约束函数。

1.4.3 收敛速度

若当前算法一定收敛,我们还需要判断收敛的速度,接下来介绍收敛的速度。

假设

  • 设向量序列 {x(k)Rn}\{ x^{(k)} \subset R^n \} 收敛于 xx^*

定义

  1. 误差序列:ek=x(k)xe_k = x^{(k)} - x^*
  2. 收敛率表达式:limkek+1ekr=C\displaystyle \lim_{k \to \infty} \frac{|| e_{k+1} ||}{||e_k||^r} = C,并称序列 {x(k)}\{x^{(k)}\}rr 阶以 CC 为因子收敛于 xx^*

线性收敛

  • 定义:r=1,0<C<1r=1,0 < C<1
  • 性质:CC 越小,算法收敛越快

超线性收敛

  • 定义:r=1,C=0r=1,C=0

  • 定理:r>1r>1​ 时的算法均为超线性收敛算法

    证明

1.4.4 迭代的终止条件

  • 对于收敛速度比较慢的算法:

    根据一阶必要条件,我们知道最优解一定取在微分算子为 0 的向量上,因此我们以下式为终止条件是一种可行的选择

    f(x(k)ϵ||\nabla f(x^{(k)}|| \le \epsilon

    但是问题在于这对收敛速度很快的算法不使用,如下图的无约束最优化问题。已知两个局部最优解分别为 x1,x2x_1^*,x_2^*,迭代的两个解分别为 x1,x2\overline{x}_1,\overline{x}_2,可以看出:尽管 2 号点相对于 1 号点更靠近局部最优解,但是由于 2 号点的梯度更大,明显不如 1 号点更加局部最优,因此利用微分算子作为迭代的终止条件不适用于收敛速度快的算法

    无约束最优化问题

  • 对于超线性收敛的算法:

    一个理想的收敛终止条件为 x(k)xϵ||x^{(k)} - x^*|| \le \epsilon,但是由于最优解 xx^* 是未知的,因此该方案不可行,同理 f(x)f(x^*) 也是未知的,因此 f(x(k))f(x)ϵ||f(x^{(k)}) - f(x^*)|| \le \epsilon 也是不可行的。那么有没有什么替代的方案呢?答案是有的。

    1. 方案一:利用函数解序列进行替代。即以下式作为迭代的终止条件

      x(k+1)x(k)ϵ|| x^{(k+1)} - x^{(k)} || \le \epsilon

      证明

    2. 方案二:利用函数值序列进行替代。即以下式作为迭代的终止条件

      f(x(k+1))f(x(k))ϵ|| f(x^{(k+1)}) - f(x^{(k)}) || \le \epsilon

      证明

  • 一般情况下,对于上述超线性算法的判断收敛的方法,只用其中一种往往不适当。此时一般使用两种方法集成的思路进行判断。

1.4.5 修正量的确定

本书介绍的迭代修正值都是使得当前迭代后的值小于上一个状态的函数值,我们称这类使评价函数值下降的算法为单调下降算法。至于如何得出修正量,我们往往通过求解一个相对简单易于求解的最优化问题(通常成为子问题),来计算获得修正量。

第二章「约束最优化」线性规划

目标函数和约束函数都是线性函数。

2.1 线性规划问题和基本性质

2.1.1 线性规划问题

线性规划问题的一般形式

2.1.2 图解法

仅适用于二元变量的线性规划问题。我们将所有的约束条件全部画到平面直角坐标系中构成一个可行域,然后将目标函数作为一条直线进行平移,直到与可行域初次有交点,则该交点就是最优解对应的点。当然不一定会有交点,一共分为四种情况:

刚好只有一个最优解

有无穷多最优解

有无界解

无可行解 - 可行域为空集

2.1.3 基本性质

  1. 线性规划问题的可行域如果非空,则是一个凸集

    证明

  2. 如果线性规划问题有最优解,那么最优解可在可行域的顶点中确定

  3. 如果可行域有界且可行域只有有限个顶点,则问题的最优解必存在,并在这有限个顶点中确定

  4. 最优解可由最优顶点处的有效约束形成的方程组的解确定

2.1.4 线性规划的标准形

为什么要学习线性规划的标准形?

  • 我们要学习单纯形法来计算多维线性规划的最优解
  • 单纯形法需要线性规划的具有所谓的标准形

线性规划的标准形的定义:

  • 只含有线性等式约束和对变量的非负约束

一般线性规划转化为标准形的方法:

  1. 对于目标函数:需要将取 max\max 通过取相反数转化为取 min\min
  2. 对于约束条件:需要将不等式约束松弛转化为等式约束
    • 对于 \le 的不等式约束,等式左边加上非负新变量,从而转化为等式
    • 对于 \ge 的不等式约数,等式左边减去非负新变量,从而转化为等式
  3. 对于无约束的变量:需要将其转化为两个新变量之差(可正可负),产生了一个新的等式,如果该无约束变量存在于目标函数中,还需要将目标函数中的该变量表示为两个新变量之差

一般线性规划转化为标准形 - 演示

2.1.5 基本可行解

直接说结论:对于标准形的线性规划问题,其基本可行解就是凸集的顶点。

假设系数矩阵 Am,nA_{m, n}。基本可行解 \subset 基本解,而基本解的个数 Cnm\le C_{n}^{m}(因为分块矩阵 Bm×mB_{m \times m} 不一定可逆)。其中基本可行解需要满足解序列元素非负。如果基本变量向量对应的解中含有 0 元素,称其为退化的基本可行解,产生退化的基本可行解的原因是存在可删除的约数条件。

2.1.6 最优解的性质

  • 若可行域有界,则线性规划问题的目标函数一定可以在其可行域的顶点上达到最优。

    证明

  • 有时,目标函数可能在多个顶点处达到最大,这时在这些顶点的凸组合上也达到最大值,这时线性规划问题有无限多个最优解。

2.2 单纯形法

本目主要介绍一种常用的计算线性规划问题可行解的方法:单纯形法。其中,前三条分别介绍单纯性方法计算步骤的理论可行性,第四条具体介绍单纯形法的计算步骤与过程。

2.2.1 初始基可行解的确定

  1. 直接观察:直接看出系数矩阵中含有 m×mm \times m 的单位矩阵,则选择该单位矩阵为初始基
  2. 加松弛变量:当所有的约束条件都是 \le 时,每一个约束条件都加一个非负的松弛变量,则这 mm 个松弛变量的系数就对应了一个 m×mm \times m 的单位矩阵,选择其为初始基即可
  3. 加非负的人工变量:求解方法与常规的线性规划问题不一样,见下述 2.2.3 条。
    • 对于 == 约束,我们在约束条件的左边加上非负人工变量
    • 对于 \ge 约束,我们先在约束条件的左边减去非负松弛变量,再在约束条件的左边加上非负的人工变量

2.2.2 最优性检验

一、当前局面:已经计算出一个基本可行解

1.1 确定线性规划的目标

确定线性规划的目标

1.2 添加松弛变量

添加松弛变量

1.3 用非基变量线性表示基变量

用非基变量线性表示基变量

向量表示

二、最优性检验

最优性检验

  1. 唯一最优解判别定理:对于目标函数求解最大值的情形。若 j[m+1,n]\forall j \in [m+1, n]σj0\sigma_j \le 0 则当前基本可行解 x(k)x^{(k)} 为最优解。

  2. 无穷多最优解判别定理:在满足第一条所有的检验数非正的情况下,j[m+1,n]\exist j \in [m+1, n],有 σj=0\sigma_j=0,则该线性规划问题有无穷多最优解。

    我们可以将任意一个检验数为 0 的非基变量与基变量进行置换得到新的一个基本可行解对应的目标函数值保持不变。于是下述凸组合内的可行解都是最优解

    x(k)s.t.{k[m+1,n]σk=0\begin{aligned}& x^{(k)} \\ & s.t. \begin{cases} k &\in& [m+1, n] \\\sigma_k &=& 0\end{cases}\end{aligned}

  3. 无界解判别定理:pass

  4. 无解判别定理:可行域为空集

2.2.3 计算新的基本可行解

对于常规的线性规划问题(初始基本可行解 x(0)x^{(0)} 可以直接找到)

  1. 向量方程法
    • 确定入基变量:选择目标函数中系数最大的变量作为入基变量,即将其对应的系数列向量与出基变量对应的系数列向量进行置换
    • 确定出基变量:线性表示基方程组以后,利用变量非负的特性找到入基变量刚好取 00 时对应的变量即为出基变量(示例的第二轮迭代中出基变量即为 x5x_5
  2. 系数矩阵法
    • pass

对于添加人工变量的线性规划问题(初始基本可行解 x(0)x^{(0)} 不能直接找到,需要手动构造 x(0)x^{(0)}

  1. 大 M 法:pass
  2. 两阶段法
    • 第一阶段:pass
    • 第二阶段:pass

2.2.4 单纯形表法

向量方程法:

单纯形法的求解步骤 - 1

单纯形法的求解步骤 - 2

单纯形表法:

实战演练

代码验算

2.3 对偶与对偶单纯形法

2.3.1 确定对偶问题

已知原问题的表达式,如何求解对偶问题的表达式?我们需掌握以下对偶转换规则:

转化规则

从上图不难发现,其实就是对线性规划的三个部分进行转换:目标函数、m个约束条件、n个自变量,下面分别进行文字介绍:

  1. 约束条件:从 m 个变为 n 个
    • 常量矩阵中的元素为原问题中自变量的系数
    • 二元关系的变化取决于转化方向
      • max to min:对偶问题约束条件的符号和原问题自变量的符号相同
      • min to max:对偶问题约束条件的符号和原问题自变量的符号相反
    • 线性约束为原问题线性约束的转置
  2. 自变量:从 n 个变为 m 个
    • 二元关系的变化同样取决于转化方向
      • max to min:对偶问题自变量的符号和原问题约束条件的符号相反
      • min to max:对偶问题自变量的符号和原问题约束条件的符号相同
  3. 目标函数:自元个数从 n 个变为 m 个
    • 变元的系数就是原问题中约束条件的常量矩阵对应的元素值

实例

2.3.2 对偶定理

  • 对称性:对偶问题的对偶问题是原问题
  • 无界性:若原问题是无界解,则对偶问题无可行解
  • 对偶定理:若原问题有最优解,那么对偶问题也有最优解,且目标函数值相等
  • 互补松弛性:若原问题最优解为 xx^*,对偶问题的最优解为 yy^*,则有 xys=0,yxs=0x^* y_s=0,y^*x_s=0,其中 xs,ysx_s,y_s 分别为原问题的松弛变量和对偶问题的松弛变量

问题

对于上述问题,我们可以先写出其对偶问题:

对偶问题

显然的对于 y=(45,35)Ty^*=(\frac{4}{5},\frac{3}{5})^T,上述 2,3,4 是取不到等号的,对应的 x=(,0,0,0,)Tx^*=(-,0,0,0,-)^T。还剩两个变量 x1,x5x_1,x_5 通过 1,5 两个不等式取等进行计算:

x1+3x2=42x1+x2=3\begin{aligned}x_1+3x_2=4\\2x_1+x_2=3\end{aligned}

计算可得 x1=1,x5=1x_1=1,x_5=1,于是原问题的最优解 x=(1,0,0,0,1)Tx^*=(1, 0, 0, 0, 1)^T

2.3.3 对偶单纯形法

实战演练

代码验算

第三章 线性搜索

本章介绍最优化问题中迭代解 xk+1=xk+αkdkx_{k+1} = x_k + \alpha_kd_k 基于「线性搜索」的迭代方式。

假设搜索方向 dkd_{k} 是一个定值且一定是下降方向,我们讨论步长因子 αk\alpha_{k} 的计算选择策略。分为两步:

  1. 确定步长的搜索区间
  2. 通过精确 or 不精确算法来搜索合适的步长

本章的内容分布:

  • 3.1 介绍确定初始搜索区间的「进退法」
  • 3.2 介绍缩小搜索区间的「精确线性搜索算法」:0.618 法、斐波那契法、二分法、插值法
  • 3.3 介绍缩小搜索区间的「不精确线性搜索算法」:Armijo 准则、Goldstein 准则、Wolfe 准则

3.1 确定初始搜索区间

确定初始搜索区间 [a,b][a,b],我们利用 进退法

3.2 精确线性搜索算法

在正式开始介绍之前,我们先了解单峰函数定理

  • 定理:对于在区间 [a,b][a,b] 上的一个单峰函数 f(x)f(x)x[a,b]x^* \in [a,b] 是其极小点,x1x_1x2x_2[a,b][a,b] 上的任意两点,且 a<x1<x2<ba<x_1<x_2<b,可以通过比较 f(x1),f(x2)f(x_1),f(x_2) 的值来确定点的保留和舍弃

  • 迭代:

    1. f(x1)f(x2)f(x_1) \ge f(x_2)x[x1,b]x^* \in [x_1,b]

      保留右区间

    2. f(x1)<f(x2)f(x_1) < f(x_2)x[a,x2]x^* \in [a,x_2]

      保留左区间

    3. f(x1)=f(x2)f(x_1) = f(x_2)x[x1,x2]x^* \in [x_1,x_2]

于是迭代的关键就在于如何取点 x1x_1x2x_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
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
class ExactLinearSearch:
def __init__(self,
a: float, b: float, delta: float,
f: Callable[[float], float],
criterion: str="0.618", max_iter=100) -> None:

self.a = a
self.b = b
self.delta = delta
self.f = f
self.max_iter = max_iter

if criterion == "0.618":
new_a, new_b, x_star, count = self._f_goad()
elif criterion == "fibonacci":
new_a, new_b, x_star, count = self._f_fibo()
else: # criterion == "binary"
new_a, new_b, x_star, count = self._f_binary()

fixed = lambda x, acc: round(x, acc)
print(f"算法为:“{criterion}” 法")
print(f"共迭代:{count} 次")
print(f"左边界: {fixed(new_a, 4)}")
print(f"右边界: {fixed(new_b, 4)}")
print(f"最优解: {fixed(x_star, 4)}")
print(f"最优值: {fixed(f(x_star), 6)}\n")


def _f_goad(self) -> Tuple[float, float, float, int]:
a, b, delta, f = self.a, self.b, self.delta, self.f
count = 0

lam = a + 0.382 * (b - a)
mu = b - 0.382 * (b - a)

while count <= self.max_iter:
phi_lam = f(lam)
phi_mu = f(mu)

if phi_lam <= phi_mu:
b = mu
else:
a = lam

lam = a + 0.382 * (b - a)
mu = b - 0.382 * (b - a)

if b - lam <= delta:
return a, b, lam, count
if mu - a <= delta:
return a, b, mu, count

count += 1

return a, b, lam if f(lam) <= f(mu) else mu, count

def _f_fibo(self) -> Tuple[float, float, float, int]:
a, b, delta, f, max_iter = self.a, self.b, self.delta, self.f, self.max_iter
count = None

F = [0.0] * max_iter
F[1] = F[2] = 1
for i in range(3, max_iter):
F[i] = F[i - 1] + F[i - 2]
if F[i] >= (b - a) / delta:
count = i - 2
break

if count == None:
ValueError("区间过大或精度过高导致,找不到合适的迭代次数")

lam, mu = a, b
for i in range(3, count + 1):

lam = a + (1 - F[i - 1] / F[i]) * (b - a)
mu = b - (1 - F[i - 1] / F[i]) * (b - a)

if f(lam) <= f(mu):
b = mu
else:
a = lam

return a, b, lam if f(lam) <= f(mu) else mu, count

def _f_binary(self) -> Tuple[float, float, float, int]:
a, b, delta, f, max_iter = self.a, self.b, self.delta, self.f, self.max_iter
count = None

count = np.ceil(np.log2((b - a) / delta)).astype(int)

if count > max_iter:
ValueError("区间过大或精度过高导致迭代次数过高")

for _ in range(count):
c = (a + b) / 2.0
if f(c) >= 0.0:
b = c
else:
a = c

return a, b, c, count

分别调用 0.618 法、斐波那契法、二分法进行迭代计算:

1
2
3
4
5
6
7
8
a, b, delta = -1, 1, 0.01
f = lambda x: np.exp(-x) + np.exp(x) # 原函数
calc_goad = ExactLinearSearch(a, b, delta, f, criterion="0.618")
calc_fibo = ExactLinearSearch(a, b, delta, f, criterion="fibonacci")

a, b, delta = -3, 6, 0.1
f = lambda x: 2 * x + 2 # 导函数
calc_bina = ExactLinearSearch(a, b, delta, f, criterion="binary")

计算结果为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
算法为:“0.618” 法
共迭代:10 次
左边界: -0.0031
右边界: 0.0069
最优解: 0.0007
最优值: 2.000001

算法为:“fibonacci” 法
共迭代:11 次
左边界: -0.0225
右边界: 0.0
最优解: -0.0139
最优值: 2.000193

算法为:“binary” 法
共迭代:7 次
左边界: -1.0312
右边界: -0.9609
最优解: -0.9609
最优值: 0.078125

3.2.1 0.618 法

基于「函数值」进行选点。

按照黄金分割的比例取点 x1x_1x2x_2,不断迭代判断 f(x1)f(x_1)f(x2)f(x_2) 的值,直到 bkλk<δb_k-\lambda_k<\deltaμkak<δ\mu_k-a_k<\delta 则结束迭代,取最优解 xx^* 为对应的 λk\lambda_kμk\mu_k 即可。

3.2.2 Fibonacci 法

基于「函数值」进行选点。

kk 次迭代的区间长度是上一个区间长度的 FnkFnk+1\frac{F_{n-k}}{F_{n-k+1}}​,即:

bk+1ak+1=FnkFnk+1(bkak)b_{k+1} - a_{k+1} = \frac{F_{n-k}}{F_{n-k+1}} (b_{k} - a_{k})

经过 nn 次迭代后,得到区间 [an,bn][a_n,b_n],且 bnanδb_n-a_n \le \delta,于是可得:

bnan=F1F2(bn1an1)=F1F2F2F3(bn2an2)==F1F2F2F3Fn1Fn(b1a1)=F1Fn(b1a1)δ\begin{aligned}b_n-a_n &= \frac{F_1}{F_2}(b_{n-1}-a_{n-1}) \\&= \frac{F_1}{F_2} \frac{F_2}{F_3}(b_{n-2}-a_{n-2}) \\&= \cdots \\&= \frac{F_1}{F_2} \frac{F_2}{F_3} \cdots \frac{F_{n-1}}{F_n}(b_{1}-a_{1}) \\&= \frac{F_1}{F_n}(b_1-a_1) \le \delta\end{aligned}

在已知上界 δ\delta 的情况下可以直接计算出 nn 的值,于是迭代 nn 次即可得到最终的步长值 λn\lambda_nμn\mu_n

3.2.3 二分法

基于「一阶导数」进行选点。

单调性存在于导数上。极值点左边导数 <0<0,极值点右边导数 >0>0,于是可以进行二分搜索。

3.2.4 插值法

基于「函数值、一阶导数」进行选点。

三点二次插值法

三点二次插值法。给定初始迭代区间 [a0,b0][a_0,b_0] 和初始迭代解 t0t_0。每次在目标函数上取三个点来拟合一个二次函数,通过拟合出来的二次函数的最小值,来更新三个点为 a1,b1,t1a_1,b_1,t_1,直到区间小于上界长度 δ\delta,终止迭代,极小值就是当前状态二次函数的最小值。

那么我们如何求解当前状态二次函数的最小值呢?假定三个已知点为 x1<x2<x3x_1<x_2<x_3,且 f(x1)>f(x2)<f(x3)f(x_1)>f(x_2)<f(x_3),我们需要知道二次函数的三个参数 a,b,ca,b,c,刚好三个已知点可以得到三个方程,从而解出二次函数的三个未知数。

两点二次插值法。只不过少取了一个点,多利用了一个点的导数值来确定二次函数的三个参数罢了,其余迭代过程与三点二次插值完全一致。

3.3 不精确线性搜索算法

精确搜索有时会导致搜索时间过长,尤其是当最优解离当前点还很远时。接下来我们介绍不精确线性搜索方法,在确保每一步的函数值都有充分下降的必要条件下,确保收敛并提升计算效率。真的有这么厉害的算法吗?其实根本逻辑很简单,Armijo 准则限定了搜索方向的上界、Goldstein 准则在前者的基础上又限定了搜索方向的下界(防止过小导致收敛过慢)、Wolfe 准则在前者的基础上又完善了下界的约束(确保不会把可行解排除在搜索区间外)

符号定义:我们在已知上一步解 xkx_k 和下次迭代的下降方向 dkd_k 的基础上,需要寻找合适的 α\alpha。于是唯一变量就是 α\alpha,我们直接定义关于 α\alpha 的一元函数 ϕ(α)=f(xk+αdk)\phi(\alpha) = f(x_k + \alpha d_k)gkg_k 表示梯度。

3.3.1 Armijo 准则

只限定搜索上界,给定初始点 x0x_0、系数 ρ\rho

f(xk+αdk)ρgkTdkα+f(xk)f(x_k + \alpha d_k) \le \rho \cdot g_k^Td_k \cdot \alpha + f(x_k)

3.3.2 Goldstein 准则

又限定了搜索下界,同样给定初始点 x0x_0、系数 ρ\rho

f(xk+αdk)ρgkTdkα+f(xk)f(xk+αdk)(1ρ)gkTdkα+f(xk)\begin{aligned}f(x_k + \alpha d_k) \le &\rho \cdot g_k^Td_k \cdot \alpha + f(x_k)\\f(x_k + \alpha d_k) \ge &(1-\rho) \cdot g_k^Td_k \cdot \alpha + f(x_k)\end{aligned}

3.3.3 Wolfe 准则

完善了搜索下界的约束,即保证新点的梯度 gk+1Tdkg_{k+1}^Td_k 不低于老点梯度 gkTdkg_{k}^Td_kσ\sigma 倍。给定 x0,ρ,σx_0,\rho,\sigma

f(xk+αdk)ρgkTdkα+f(xk)g(xk+αkdk)TdkσgkTdk0<ρ<σ<1\begin{aligned}f(x_k + \alpha d_k) \le &\rho \cdot g_k^Td_k \cdot \alpha + f(x_k)\\g(x_k + \alpha_kd_k)^Td_k \ge& \sigma g_{k}^Td_k\\0 < \rho < \sigma < 1\end{aligned}

参考:

第四章「无约束最优化」

本章介绍无约束函数的最优化算法。其中:

  • 最速下降法基于「一阶梯度」。最基本的方法
  • 牛顿法基于「二阶梯度」。最主要的方法
  • 共轭梯度法基于「一阶梯度」。解大型最优化问题的首选
  • 拟牛顿法基于「函数值和一阶梯度」。尤其是其中的 BFGS 是目前最成功的方法

目标函数 ff、一阶梯度 gg、二阶梯度 GG、初始点 x0x_0

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
def f(x: np.ndarray) -> float:
return (1 - x[0])**2 + 100 * (x[1] - x[0]**2)**2


def g(x: np.ndarray) -> np.ndarray:
grad_x = -2 * (1 - x[0]) - 400 * x[0] * (x[1] - x[0]**2)
grad_y = 200 * (x[1] - x[0]**2)
return np.array([grad_x, grad_y])


def G(x: np.ndarray) -> np.ndarray:
grad_xx = 2 - 400 * x[1] + 1200 * x[0]**2
grad_xy = -400 * x[0]
grad_yx = -400 * x[0]
grad_yy = 200
return np.array([
[grad_xx, grad_xy],
[grad_yx, grad_yy]
])


initial_point = [-1.2, 1]

已知最优点为 x=(1,1)Tx^*=(1, 1)^T,最优解 f(x)=0f(x^*)=0,以书中例题初始点 (1.2,1)T(-1.2,1)^T 为例开始迭代。

一、最速下降法

1
2
3
4
5
6
7
8
9
10
11
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 gradient_descent(initial_point, max_iter=5, eps=1e-6):
x = np.array(initial_point)
points = [x]
gradients = [g(x)]
alphas = []

for _ in range(max_iter):
grad = gradients[-1]

# 搜索方向
direction = -grad

# 步长因子:无法确定准确的步长最小化函数,因此此处采用二分法搜索最优步长
alpha = 1
while f(x + alpha * direction) > f(x):
alpha /= 2

x = x + alpha * direction
points.append(x)
gradients.append(g(x))
alphas.append(alpha)

if np.linalg.norm(grad) < eps:
break

return points, gradients, alphas


points, gradients, alphas = gradient_descent(initial_point, max_iter=100, eps=1e-6)

for i, (point, grad, alpha) in enumerate(zip(points, gradients, [1] + alphas)):
print(f"Iteration {i}:")
print(f" Point = {point}")
print(f" Gradient = {grad}")
print(f" Step Size = {alpha}")
print(f" Direction = {-grad}")
print(f" Function Val= {f(point)}\n")

迭代100次后输出为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Iteration 98:
Point = [0.93432802 0.87236513]
Gradient = [ 0.0942865 -0.12074477]
Step Size = 0.00390625
Direction = [-0.0942865 0.12074477]
Function Val= 0.004349256784446673

Iteration 99:
Point = [0.93414387 0.87260096]
Gradient = [-0.12281587 -0.00476179]
Step Size = 0.001953125
Direction = [0.12281587 0.00476179]
Function Val= 0.004337086557103718

Iteration 100:
Point = [0.93438374 0.87261026]
Gradient = [ 0.04171114 -0.09254423]
Step Size = 0.001953125
Direction = [-0.04171114 0.09254423]
Function Val= 0.004326904052586884

二、牛顿法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
def newton_method(initial_point, max_iter=5, eps=1e-6):
x = np.array(initial_point)
points = [x]
gradients = [g(x)]
Hessians = [G(x)]

for _ in range(max_iter):
grad = gradients[-1]
Hessian = Hessians[-1]

# 搜索方向
direction = np.linalg.inv(Hessian) @ grad

# 步长因子:假定使用固定步长的牛顿法
alpha = 1

x = x - alpha * direction
points.append(x)
gradients.append(g(x))
Hessians.append(G(x))

if np.linalg.norm(grad) < eps:
break

return points, gradients, Hessians


points, gradients, Hessians = newton_method(initial_point, max_iter=50, eps=1e-6)

for i, (point, grad, Hessian) in enumerate(zip(points, gradients, Hessians)):
print(f"Iteration {i}:")
print(f" Point = {point}")
print(f" Gradient = {grad}")
print(f" Hessian = {Hessian}")
print(f" Function Val= {f(point)}\n")

迭代7次即收敛:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Iteration 5:
Point = [0.9999957 0.99999139]
Gradient = [-8.60863343e-06 -2.95985458e-11]
Hessian = [[ 801.99311306 -399.99827826]
[-399.99827826 200. ]]
Function Val= 1.8527397192178054e-11

Iteration 6:
Point = [1. 1.]
Gradient = [ 7.41096051e-09 -3.70548037e-09]
Hessian = [[ 802.00000001 -400. ]
[-400. 200. ]]
Function Val= 3.4326461875363225e-20

Iteration 7:
Point = [1. 1.]
Gradient = [-0. 0.]
Hessian = [[ 802. -400.]
[-400. 200.]]
Function Val= 0.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
def conjugate_gradient(initial_point, max_iter=5, eps=1e-6):
x = np.array(initial_point)
points = [x]
gradients = [g(x)]
directions = [-g(x)]
alphas = []

for i in range(max_iter):
grad = gradients[-1]

# 搜索方向:FR公式
if i == 0:
direction = -grad
else:
beta = np.dot(g(x), g(x)) / np.dot(g(points[-2]), g(points[-2]))
direction = -g(x) + beta * direction

# 步长因子:精确线搜索直接得到闭式解
alpha = -np.dot(grad, direction) / np.dot(direction, G(x) @ direction)


x = x + alpha * direction

directions.append(direction)
alphas.append(alpha)
points.append(x)
gradients.append(g(x))

if np.linalg.norm(grad) < eps:
break

return points, gradients, alphas


points, gradients, alphas = conjugate_gradient(initial_point, max_iter=1000, eps=1e-6)

for i, (point, grad, alpha) in enumerate(zip(points, gradients, alphas)):
print(f"Iteration {i}:")
print(f" Point = {point}")
print(f" Gradient = {grad}")
print(f" Step Size = {alpha}")
print(f" Direction = {-grad}")
print(f" Function Val= {f(point)}\n")

迭代1000次后输出为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Iteration 997:
Point = [0.9999994 0.99999875]
Gradient = [ 1.90794906e-05 -1.01414007e-05]
Step Size = 0.0005161468619784313
Direction = [-1.90794906e-05 1.01414007e-05]
Function Val= 6.191018745155016e-13

Iteration 998:
Point = [0.99999931 0.99999861]
Gradient = [ 5.43686111e-06 -3.40374227e-06]
Step Size = 0.0005078748917694624
Direction = [-5.43686111e-06 3.40374227e-06]
Function Val= 4.986125950068217e-13

Iteration 999:
Point = [0.9999993 0.9999986]
Gradient = [ 1.34784246e-06 -1.36924747e-06]
Step Size = 0.0005356250138048412
Direction = [-1.34784246e-06 1.36924747e-06]
Function Val= 4.881643528976312e-13

四、拟牛顿法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
def bfgs(initial_point, max_iter=5, eps=1e-6):
x = np.array(initial_point)
points = [x]
gradients = [g(x)]
B = G(x)

for _ in range(max_iter):
grad = gradients[-1]

# 迭代公式
x = x - np.linalg.inv(B) @ grad

# 更新 B 矩阵
s = x - points[-1]
y = g(x) - gradients[-1]
B = B + np.outer(y, y) / (s @ y) - (B @ np.outer(s, s) @ B) / (s @ B @ s)

points.append(x)
gradients.append(g(x))

if np.linalg.norm(grad) < eps:
break

return points, gradients


points, gradients = bfgs(initial_point, max_iter=1000, eps=1e-6)

for i, (point, grad) in enumerate(zip(points, gradients)):
print(f"Iteration {i}:")
print(f" Point = {point}")
print(f" Gradient = {grad}")
print(f" Function Val= {f(point)}\n")

迭代 78 次收敛:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Iteration 76:
Point = [1.00000075 0.99999921]
Gradient = [ 0.000918 -0.00045825]
Function Val= 5.255482679080297e-10

Iteration 77:
Point = [1.00000006 1.00000012]
Gradient = [ 6.46055099e-07 -2.63592925e-07]
Function Val= 3.7061757712619374e-15

Iteration 78:
Point = [1. 1.]
Gradient = [6.75185684e-10 4.68913797e-10]
Function Val= 6.510026595267928e-19

4.1 最速下降法

放一张生动的图:

最速下降法 - 迭代示意图

迭代公式:

xk+1=xkαkf(xk)x_{k+1} = x_k - \alpha_k \nabla f(x_k)

每次迭代时,下降方向 dkd_k 采用当前解 xkx_k 处的负梯度方向 f(xk)- \nabla f(x_k)步长因子 αk\alpha_k 采用精确线性搜索算法的计算结果。

易证相邻迭代解 xk,xk+1x_k,x_{k+1} 的方向 dk,dk+1d_k,d_{k+1} 是正交的:由于 ϕ(α)=f(xk+αdk)\phi(\alpha) = f(x_k + \alpha d_k),在采用线搜索找最优步长时,步长的搜索结果 αk\alpha_k 即为使得 ϕ(α)=0\phi'(\alpha)=0 的解,于是可得 0=ϕ(α)=ϕ(αk)=f(xk+αkdk)dk=dk+1Tdk0=\phi'(\alpha) = \phi'(\alpha_k) = \nabla f(x_k+\alpha_k d_k)d_k = -d_{k+1}^T \cdot d_k,即 dk+1Tdk=0d_{k+1}^T \cdot d_k = 0。如图:

相邻迭代解的方向正交

也正因为搜索方向正交的特性导致最速下降法的收敛速度往往不尽如人意。优点在于程序设计简单并且计算和存储量都不大,以及对初始点的要求不高。

4.2 牛顿法

放一张生动的图:

牛顿法 - 迭代示意图

注:本图演示的是求解一维函数零点的迭代过程,需要使得 f(x)=0f(x)=0,因此比值式为原函数除以导函数。后续介绍的是极小化函数的过程,需要使得 f(x)=0f'(x)=0,因此比值式为一阶导数除以二阶导数,高维就是二阶梯度的逆乘以一阶梯度。

迭代公式:

xk+1=xk2f(xk)1f(xk)x_{k+1} = x_k - \nabla^2f(x_k)^{-1}\nabla f(x_k)

牛顿法相较于最速下降法有了更快的收敛速度,但是由于需要计算和存储海塞矩阵导致计算量增加并且有些目标函数可能根本求不出二阶梯度。同时牛顿法对于初始迭代点的选择比最速下降法要苛刻的多。

4.3 共轭梯度法

我们利用共轭梯度法解决「正定二次函数」的极小化问题。由于最速下降法中相邻迭代点的方向是正交的导致搜索效率下降,牛顿法又由于需要计算和存储海塞矩阵导致存储开销过大,共轭梯度法的核心思想是相邻两个迭代点的搜索方向是关于正定二次型的正定阵正交的。这样既保证了迭代收敛的速度也避免了计算存储海塞矩阵的开销。美中不足的是当共轭梯度法解决其他问题是往往会出现对线搜索的过度依赖,一旦线搜索变差会导致整个迭代过程精度变差。

概念补充:

  1. 共轭:xxyy 共轭当且仅当 xTGy=0x^TGy=0,其中 G 为对称正定阵
  2. 正定二次:f(x)=12xTGxbTx+cf(x) =\frac{1}{2} x^TGx - b^Tx + c

公式推导:

  1. 首先给定初始迭代点 x0x_0,收敛阈值 ϵ\epsilon,迭代公式是不变的:xk+1=xk+αkdkx_{k+1} = x_k + \alpha_k d_k,关键在于计算每一次迭代过程中的步长因子 αk\alpha_k​ 和搜索方向 dkd_k

  2. 确定步长因子 αk\alpha_k

    α=minαϕ(α)=minαf(xk+αdk)=minα12(xk+αdk)TG(xk+αdk)bT(xk+αdk)+c=minα12(xkTGxk+2αxkTGdk+α2dKTdk)bTxkbTαdk+c=minα12xkTGxk+αxkTGdk+12α2dkTGdkbTαdk+c\begin{aligned}\alpha &= \min_{\alpha} \phi(\alpha) \\&= \min_{\alpha} f(x_k+\alpha d_k) \\&= \min_{\alpha} \frac{1}{2}(x_k+\alpha d_k)^TG(x_k+\alpha d_k) - b^T(x_k+\alpha d_k) + c \\&= \min_{\alpha} \frac{1}{2} (x_k^TGx_k + 2\alpha x_k^T G d_k + \alpha^2d_K^Td_k) - b^Tx_k - b^T \alpha d_k + c\\&= \min_{\alpha} \frac{1}{2} x_k^TGx_k + \alpha x_k^T G d_k + \frac{1}{2}\alpha^2d_k^T G d_k - b^T \alpha d_k + c\end{aligned}

    由于目标函数是正定二次型,显然可以直接求出步长因子的闭式解:

    dϕ(α)dα=xkTGdk+αdkTGdkbTdk=0\begin{aligned}\frac{d \phi(\alpha)}{d\alpha} &= x_k^T G d_k + \alpha d_k^T G d_k - b^Td_k \\&=0\end{aligned}

    于是可以导出当前的步长因子 αk\alpha_k 的闭式解为:

    αk=(bTxTG)dkdkTGdk=gkTdkdkTGdk\begin{aligned}\alpha_k &= \frac{(b^T - x^TG)d_k}{d_k^TGd_k} \\&= -\frac{g_k^T d_k}{d_k^TGd_k}\end{aligned}

  3. 确定搜索方向 dkd_k

    dk=gk+βdk1d_k = -g_k + \beta d_{k-1}

    可见只需要确定组合系数 β\beta。由于共轭梯度法遵循相邻迭代点的搜索方向共轭,即 dk1TGdk=0d_{k-1}^TGd_k=0,因此对上式两侧同时左乘 dk1TGd_{k-1}^TG,有:

    dk1TGdk=dk1TGgk+βdk1TGdk1=0\begin{aligned}d_{k-1}^TGd_k &= -d_{k-1}^TGg_k + \beta d_{k-1}^TGd_{k-1} \\&= 0\end{aligned}

    于是可得当前的组合系数 β\beta 为:

    β=dk1TGgkdk1TGdk1\beta = \frac{d_{k-1}^TGg_k}{d_{k-1}^TGd_{k-1}}

    上述组合系数 β\beta 的结果是共轭梯度最原始的表达式,后人又进行了变形,没证出来,难崩,直接背吧,给出 FR 的组合系数表达式:

    β=gkTgkgk1Tgk1\begin{aligned}\beta = \frac{g_k^Tg_k}{g_{k-1}^T g_{k-1}}\end{aligned}

    当然了由于初始迭代时没有前一个搜索方向,因此直接用初始点的梯度作为搜索方向,即:

    d0=g0d_0=-g_0

    于是可以导出当前的搜索方向 dkd_k 的闭式解为:

    dk=gk+gkTgkgk1Tgk1dk1d_k = -g_k + \frac{g_k^Tg_k}{g_{k-1}^T g_{k-1}} d_{k-1}

迭代公式:

xk+1=xk+αkdk=xk+(gkTdkdkTGdk)(gk+gkTgkgk1Tgk1dk1)\begin{aligned}x_{k+1} =& x_k + \alpha_k d_k\\=& x_k + (-\frac{g_k^T d_k}{d_k^TGd_k}) (-g_k + \frac{g_k^Tg_k}{g_{k-1}^T g_{k-1}} d_{k-1})\end{aligned}

4.4 拟牛顿法

4.1 和 4.2 介绍的基于一阶梯度和二阶梯度的下降法都可以统一成下面的表达式:

xk+1=xkαkBkf(xk)x_{k+1} = x_k - \alpha_k B_k \nabla f(x_k)

  • 4.1 的最速下降法的步长因子通过精确线搜索获得,海塞矩阵的逆 BkB_k 不存在,可以看做为单位阵 EE
  • 4.2 的牛顿法的步长因子同样可以通过精确线搜索获得,当然也可以设置为定值,海塞矩阵的逆 BkB_k 对应二阶梯度的逆 (2f(xk))1(\nabla^2 f(x_k))^{-1}

前者收敛速度差、后者计算量和存储量大,我们尝试构造一个对称正定阵 BkB_k 来近似代替二阶梯度的逆,即 Bk(2f(xk))1B_k \approx (\nabla^2 f(x_k))^{-1},使得该法具备较快的收敛速度与较少的内存开销。

介绍最著名的 BFGS 拟牛顿法,它的核心思想是每次迭代过程中对其进行快速校正,从而在确保收敛速度的情况下提升计算效率。迭代公式如下:

xk+1=xkBk1gk记: {sk=xk+1xkyk=gk+1gk则: {B0=2f(x0)Bk+1=Bk+ykykTskTykBkskskTBkskTBksk\begin{aligned}&x_{k+1} = x_k - B_k^{-1}g_k \\&\text{记: }\begin{cases}s_k= x_{k+1} - x_k\\y_k=g_{k+1} - g_k\end{cases}\\&\text{则: }\begin{cases}B_0 = \nabla^2f(x_0)\\\displaystyle B_{k+1} = B_k + \frac{y_ky_k^T}{s_k^Ty_k} - \frac{B_ks_ks_k^TB_k}{s_k^TB_ks_k}\end{cases}\end{aligned}

参考:

第五章「无约束最优化」最小二乘

本章继续介绍无约束函数的最优化算法。不过和第四章的区别在于现在的目标函数是二次函数,称为「最小二乘问题」。

所谓的无约束最小二乘问题,本质上是第四章介绍的无约束问题的一个子集,只不过因为使用场景很多所以单独拿出来进行讨论。也正因为使用场景多,学者们针对此类问题设计出了更加高效的最优化算法。

无约束最小二乘问题的形式定义为:

minxRnf(x)=12i=1m[ri(x)]2,mn\begin{aligned}\min_{x\in R^n}f(x)=\frac{1}{2}\sum_{i=1}^m[r_{i}(x)]^2,\quad m\ge n\end{aligned}

其中 ri(x)r_i(x) 称为「残量函数」。本质上最小二乘问题就是寻找一个函数 f(x,αi),(i=1,2,,m)f(x,\alpha_i),(i=1,2,\cdots,m) 来拟合 bb,于是问题就转化为了

mini=1m[ri(x)]2=mini=1m[f(x,αi)bi]2\begin{aligned}\min { \sum_{i=1}^m [r_i(x)]^2 }=\min { \sum_{i=1}^m [f(x,\alpha_i) - b_i]^2 }\end{aligned}

ri(x)r_i(x) 为线性函数时,当前问题为线性最小二乘问题;当 ri(x)r_i(x) 为非线性函数时,当前问题为非线性最小二乘问题。本章将分别讨论这两种最小二乘问题的优化求解策略。

5.1 线性最小二乘

此时可以直接将目标函数写成:

minf(x)=12Axb2=12xTATAxbTAx+12bTb\begin{aligned}\min f(x)&=\frac{1}{2}|| Ax-b ||^2\\&= \frac{1}{2}x^TA^TAx-b^TAx+\frac{1}{2}b^Tb\end{aligned}

利用一阶必要条件可得:

f(x)=ATAxATb=0\begin{aligned}\nabla f(x)&=A^TAx - A^Tb\\&=0\end{aligned}

于是可得最优闭式解:

x=(ATA)1ATbx^*=(A^T A)^{-1}A^Tb

当然 ATAA^TA 并不都是可逆的,并且在数据量足够大时,即使可逆也会让求逆操作即为耗时。针对此问题,提出了线性最小二乘的 QR 正交分解算法。

5.2 非线性最小二乘

同样可以采用第四章学到的各种下降迭代算法,这里引入高斯牛顿法,推导的解的迭代公式为:

xk+1=xk(AkTAk)1AkTrkx^{k+1}=x^k - (A_k^TA_k)^{-1}A_k^Tr_k

其中:

Ak=[r1(xk)r2(xk)rm(xk)],rk=[r1(xk)r2(xk)rm(xk)]\begin{aligned}A_k = \begin{bmatrix}\nabla r_1(x_k)\\\nabla r_2(x_k)\\\vdots \\\nabla r_m(x_k)\end{bmatrix},\quadr_k = \begin{bmatrix}r_1(x_k)\\r_2(x_k)\\\vdots \\r_m(x_k)\end{bmatrix}\\\end{aligned}

参考:

第六章「约束最优化」二次规划

目标函数是二次函数,约束函数是线性函数。一般形式为:

minq(x)=12xTGx+gTxs.t.{aiTx=bi,iSaiTxbi,iM\begin{aligned}&\min \quad q(x) = \frac{1}{2}x^TGx + g^Tx \\&s.t.\quad \begin{cases}a_i^Tx = b_i& ,i\in S\\a_i^Tx \ge b_i& ,i \in M\\\end{cases}\end{aligned}

本章将分别讨论约束函数「含有等式」和「含有不等式」两类二次规划问题。

6.1 等式约束二次规划

我们引入拉格朗日方法 (Lagrange Method, 简称 LM)。此时可以用矩阵表示问题和约束条件,并且不加证明的给出最优解和对应乘子就是满足 KKT 条件下的解。

拉格朗日函数:

L(x,λ)=12xTGx+gTxλT(ATxb)L(x,\lambda) = \frac{1}{2}x^TGx+g^Tx -\lambda^T(A^Tx - b)

一阶必要条件:

L(x,λ)x=Gx+gAλ=0L(x,λ)λ=Axb=0\begin{aligned}\frac{\partial L(x, \lambda) }{\partial x} &= Gx+g-A\lambda = 0 \\\frac{\partial L(x, \lambda) }{\partial \lambda} &= A^x - b = 0\end{aligned}

最优解的矩阵形式:

[GAAT0][xλ]=[gb]\begin{aligned}\begin{bmatrix}G & -A \\-A^T & 0\end{bmatrix}\begin{bmatrix}x^* \\\lambda^*\end{bmatrix}=-\begin{bmatrix}g \\b\end{bmatrix}\end{aligned}

6.2 不等式约束二次规划

我们引入有效集方法 (Active Set Method, 简称 ASM)。首先显然的最优解一定成立于等式约束,或成立于不等式取等。我们可以直接枚举每一种约束条件的组合(所有等式+枚举的不等式,并将不等式看做等式约束),然后判定当前的解是否满足没有选择的不等式约束。这种方法是可行的但是计算量极大。于是有效集方法应运而生。

算法流程:

Primal ASM 算法

算法解析:

  • 计算初始可行点 x(0)x^{(0)},可用线性规划中的大 M 法计算获得
  • 计算前进方向 pkp_k,通过求解等式约束二次规划子问题获得
  • pk=0p_k = \bf{0}​,则计算工作集约束 WkW_k 对应的所有拉格朗日乘子 λi\lambda_i
    • 若所有 λi0\lambda_i \ge 0,则停止迭代,得到最优解 x=x(k)x^*=x^{(k)}
    • 若存在 λi<0\lambda_i < 0,则在工作集中剔除 λi\lambda_i 最小时对应的约束 aja_j,得 Wk+1=Wk{aj}W_{k+1}=W_k \setminus \set{a_j}
  • pk0p_k \ne \bf{0},则计算步长因子 αk\alpha_k,并置 x(k+1)=x(k)+αkpkx^{(k+1)} = x^{(k)} + \alpha_kp_k
    • αk=1\alpha_k=1,则工作集不变,得 Wk+1=WkW_{k+1}=W_k
    • αk<1\alpha_k < 1,则在工作集中添加 αk\alpha_k 最小时对应的约束 aja_j,得 Wk+1=Wk{aj}W_{k+1}=W_k \bigcup \set {a_j}

参考:

第七章「约束最优化」

本章我们简单讨论约束最优化问题中,针对等式约束的「二次罚函数」算法,以及拉格朗日乘子法。

二次罚函数法

对于等式约束问题:

minf(x)s.t.ai(x)=0, i=1,2,...,p\begin{aligned}&\min\quad f(x) \\&s.t.\quad a_i(x) = 0,\ i=1,2,...,p\end{aligned}

我们定义二次罚函数 PE(x,σ)P_E(x, \sigma),其中 σ\sigma 为惩罚因子:

PE(x,σ)=f(x)+12σi=1pai2(x)P_E(x, \sigma) = f(x) + \frac{1}{2} \sigma\sum_{i=1}^p a_i^2(x)

不加证明的给出结论:当惩罚因子 \to \infty 时,目标函数趋于最小值。下面给出算法流程:

等式约束的二次罚函数法

拉格朗日乘子法

目标函数:

目标函数

拉格朗日函数:

拉格朗日函数

KKT 条件:

KKT 条件

在实际求解时,我们只需要罗列上述 KKT 条件中的 (1) 和 (5),另外三个显然不需要再罗列了。我们只需要对 mm 个不等式约束条件对应的乘子进行是否为零的讨论即可,显然需要讨论 2m2^m 次。

后记

下面罗列一下考试题型(考前就透露完了🤣):

一、选择 6 * 2'

  • 严格凸函数的定义(ch1.2)(判断海塞矩阵是否正定即可)

    考虑函数 f(x,y)=x2+y2f(x, y) = x^2 + y^2。我们计算它的 Hessian 矩阵:

    1. 计算一阶导数:

      fx=fx=2xf_x = \frac{\partial f}{\partial x} = 2x

      fy=fy=2yf_y = \frac{\partial f}{\partial y} = 2y

    2. 计算二阶导数:

      fxx=2fx2=2f_{xx} = \frac{\partial^2 f}{\partial x^2} = 2

      fyy=2fy2=2f_{yy} = \frac{\partial^2 f}{\partial y^2} = 2

      fxy=2fxy=0f_{xy} = \frac{\partial^2 f}{\partial x \partial y} = 0

      fyx=2fyx=0f_{yx} = \frac{\partial^2 f}{\partial y \partial x} = 0

    3. 构造 Hessian 矩阵:

      H=(fxxfxyfyxfyy)=(2002)H = \begin{pmatrix}f_{xx} & f_{xy} \\f_{yx} & f_{yy}\end{pmatrix} = \begin{pmatrix}2 & 0 \\0 & 2\end{pmatrix}

    4. 检查 Hessian 矩阵是否为正定矩阵。对于一个 2x2 的对称矩阵:

      H=(abbc)H = \begin{pmatrix}a & b \\b & c\end{pmatrix}

      要判断它是否正定,可以使用以下条件:

      • a>0a > 0
      • 矩阵的行列式 acb2>0ac - b^2 > 0

    对于我们的 Hessian 矩阵 HH

    a=2,b=0,c=2 a = 2, \quad b = 0, \quad c = 2

    显然:

    2>0 2 > 0

    行列式=2202=4>0 \text{行列式} = 2 \cdot 2 - 0^2 = 4 > 0

    因此,Hessian 矩阵 HH 是正定的,所以函数 f(x,y)=x2+y2f(x, y) = x^2 + y^2 是严格凸函数。

  • 海塞矩阵正定负定和极值的关系(ch1.3)(显然的)

    驻点的性质与海塞矩阵的行列式(determinant,det)和特征值(eigenvalues)的符号密切相关。以下是具体的关系:

    1. 正定矩阵:如果海塞矩阵 HH 是正定矩阵(即 det(H)>0\text{det}(H) > 0fxx>0f_{xx} > 0),则驻点是局部极小值点。这是因为此时所有的特征值都是正的,函数在该点附近是向上的碗形结构。

    2. 负定矩阵:如果海塞矩阵 HH 是负定矩阵(即 det(H)>0\text{det}(H) > 0fxx<0f_{xx} < 0),则驻点是局部极大值点。这是因为此时所有的特征值都是负的,函数在该点附近是向下的碗形结构。

    3. 不定矩阵:如果海塞矩阵 HH 的行列式小于零(即 det(H)<0\text{det}(H) < 0),则驻点是鞍点。这是因为此时特征值的符号不同,函数在该点附近的形状像马鞍。

    4. 退化矩阵:如果海塞矩阵 HH 的行列式等于零(即 det(H)=0\text{det}(H) = 0),则无法通过二阶导数判断驻点的性质,需要进一步分析。这是因为此时海塞矩阵是奇异的,不能提供足够的信息来确定驻点的性质。

    总结以上内容可以得出如下的关系:

    • det(H)>0\text{det}(H) > 0fxx>0f_{xx} > 0:局部极小值点。
    • det(H)>0\text{det}(H) > 0fxx<0f_{xx} < 0:局部极大值点。
    • det(H)<0\text{det}(H) < 0:鞍点。
    • det(H)=0\text{det}(H) = 0:需要进一步分析。
  • 求二元函数的极值(ch1.3)(先利用一阶必要条件求出所有驻点,然后利用海塞矩阵的正定性判定极值点。答案:一个极大一个极小,另两个什么都不是)

    问题:

    给定函数 f(x,y)=x33x+y33yf(x, y) = x^3 - 3x + y^3 - 3y,求其所有极值点。

    解题步骤:

    1. 计算一阶梯度以求驻点

      计算函数 f(x,y)f(x, y)xxyy 的一阶偏导数:

      fx=3x23\frac{\partial f}{\partial x} = 3x^2 - 3

      fy=3y23\frac{\partial f}{\partial y} = 3y^2 - 3

      解方程组 fx=0\frac{\partial f}{\partial x} = 0fy=0\frac{\partial f}{\partial y} = 0

      3x23=03x^2 - 3 = 0

      3y23=03y^2 - 3 = 0

      化简方程组得到:

      x2=1    x=±1x^2 = 1 \implies x = \pm 1

      y2=1    y=±1y^2 = 1 \implies y = \pm 1

      所以,驻点为 (1,1)(1, 1)(1,1)(1, -1)(1,1)(-1, 1)(1,1)(-1, -1)

    2. 计算二阶梯度以检验驻点的性质

      计算二阶偏导数:

      2fx2=6x\frac{\partial^2 f}{\partial x^2} = 6x

      2fy2=6y\frac{\partial^2 f}{\partial y^2} = 6y

      2fxy=0\frac{\partial^2 f}{\partial x \partial y} = 0

      对于每个驻点,计算 Hessian 矩阵:

      H=(6x006y)H = \begin{pmatrix}6x & 0 \\0 & 6y\end{pmatrix}

      驻点 (1, 1):

      H(1,1)=(6006)H(1,1) = \begin{pmatrix}6 & 0 \\0 & 6\end{pmatrix}

      行列式为:

      det(H(1,1))=6×60×0=36>0\text{det}(H(1,1)) = 6 \times 6 - 0 \times 0 = 36 > 0

      主对角线元素 6>06 > 0,所以 (1,1)(1, 1)局部极小值点

      驻点 (-1, -1):

      H(1,1)=(6006)H(-1,-1) = \begin{pmatrix}-6 & 0 \\0 & -6\end{pmatrix}

      行列式为:

      det(H(1,1))=6×60×0=36>0\text{det}(H(-1,-1)) = -6 \times -6 - 0 \times 0 = 36 > 0

      主对角线元素 6<0-6 < 0,所以 (1,1)(-1, -1)局部极大值点

      驻点 (1, -1):

      H(1,1)=(6006)H(1,-1) = \begin{pmatrix}6 & 0 \\0 & -6\end{pmatrix}

      行列式为:

      det(H(1,1))=6×60×0=36<0\text{det}(H(1,-1)) = 6 \times -6 - 0 \times 0 = -36 < 0

      由于行列式为负,(1,1)(1, -1)鞍点

      驻点 (-1, 1):

      H(1,1)=(6006)H(-1,1) = \begin{pmatrix}-6 & 0 \\0 & 6\end{pmatrix}

      行列式为:

      det(H(1,1))=6×60×0=36<0\text{det}(H(-1,1)) = -6 \times 6 - 0 \times 0 = -36 < 0

      由于行列式为负,(1,1)(-1, 1)鞍点

    结论:

    函数 f(x,y)=x33x+y33yf(x, y) = x^3 - 3x + y^3 - 3y 在点 (1,1)(1, 1) 处有一个局部极小值点,在点 (1,1)(-1, -1) 处有一个局部极大值点,而在点 (1,1)(1, -1)(1,1)(-1, 1) 处为鞍点。

  • 拉格朗日函数中等式乘子的性质(ch1.3)(等式约束的乘子 λi\lambda_i 是任意实数,不等式约束的乘子 μi0\mu_i\ge 0

二、填空 5 * 2'

  • 求解给定点的下降方向(ch1.3)

    求解一个函数的下降方向(Descent Direction),可以使用其梯度(Gradient)。梯度的反方向是函数值下降最快的方向。具体来说:

    1. 一元函数:

      • 对于一元函数 f(x)f(x),其梯度是导数 f(x)f'(x)
      • 下降方向是梯度的负方向,即 f(x)-f'(x)

      例子:
      f(x)=x2+2x+1f(x) = x^2 + 2x + 1

      • 首先计算导数 f(x)=2x+2f'(x) = 2x + 2
      • 在某点 x0x_0 处,下降方向是 f(x0)=(2x0+2)-f'(x_0) = -(2x_0 + 2)
    2. 二元函数:

      • 对于二元函数 f(x,y)f(x, y),其梯度是偏导数的向量 f(x,y)=(fx,fy)\nabla f(x, y) = \left( \frac{\partial f}{\partial x}, \frac{\partial f}{\partial y} \right)
      • 下降方向是梯度的负方向,即 f(x,y)=(fx,fy)-\nabla f(x, y) = \left( -\frac{\partial f}{\partial x}, -\frac{\partial f}{\partial y} \right)

      例子:
      f(x,y)=x2+y2+2x+2yf(x, y) = x^2 + y^2 + 2x + 2y

      • 首先计算偏导数:

        fx=2x+2\frac{\partial f}{\partial x} = 2x + 2

        fy=2y+2\frac{\partial f}{\partial y} = 2y + 2

      • 在某点 (x0,y0)(x_0, y_0) 处,梯度是 f(x0,y0)=(2x0+2,2y0+2)\nabla f(x_0, y_0) = (2x_0 + 2, 2y_0 + 2)
      • 下降方向是 f(x0,y0)=((2x0+2),(2y0+2))-\nabla f(x_0, y_0) = (-(2x_0 + 2), -(2y_0 + 2))
  • 线性规划的基本概念(ch2.1)

    这个不知道要怎么考,大致罗列一下所有的基本概念。

    • 定义:目标函数和约束条件都是线性的
    • 图解法:对于二元函数,可以使用平面图法进行求解。那么共有 4 种可能的结果,分别为:有唯一解、有无穷多解、有无界解、无可行解
    • 性质:分别从可行域和最优解两个角度展开:
      • 对于可行域,如果可行域是非空则可行域一定是凸集,这个很显然,用 ch1.2 的可行域凸集定理证明即可
      • 对于最优解,首先最优解如果存在则一定存在于所有约束条件中取等时,其次最优解如果存在一定是在可行域的顶点上。
  • 拟牛顿法的近似海塞矩阵公式(ch4.4)(见书 4.4.9和 4.4.10)

    对于原函数,有:

    Gk+1(xk+1xk)gk+1gkG_{k+1}(x_{k+1}-x_k) \approx g_{k+1} - g_k

    我们希望构造出的对称矩阵 Bk+1B_{k+1} 满足上式中 Gk+1G_{k+1} 的条件,于是就有下面两种拟牛顿条件,其中 Hk=Bk1H_k=B_k^{-1}

    Bk+1(xk+1xk)=gk+1gkHk+1(gk+1gk)=xk+1xk\begin{aligned}B_{k+1}(x_{k+1}-x_k)= g_{k+1} - g_k\\H_{k+1}(g_{k+1} - g_k) = x_{k+1}-x_k\end{aligned}

    我们记 sk=xk+1xk,yk=gk+1gks_k=x_{k+1}-x_k,y_k=g_{k+1} - g_k。则关于 Bk+1B_{k+1} 的 BFGS 校正迭代公式如下:

    {B0=2f(x0)Bk+1=Bk+ykykTskTykBkskskTBkskTBksk\begin{cases}B_0 = \nabla^2f(x_0)\\\displaystyle B_{k+1} = B_k + \frac{y_ky_k^T}{s_k^Ty_k} - \frac{B_ks_ks_k^TB_k}{s_k^TB_ks_k}\end{cases}

三、证明 1 * 10'

  • 证明高维凸规划问题(ch1.2)

    关键在于证明可行域是凸集,目标函数是凸函数。证明可行域是凸集比较简单,书 ch1.2 中的「定理1.2.18」给出了详细的可行域凸性判定定理。证明目标函数是凸函数有三种方法,书 ch1.2 中「定理1.2.19-1.2.21」分别从函数值、一阶导数、二阶导数三个角度进行了凸函数判定定理的介绍,下面仅从二阶导数的角度给出凸函数判定的示例。

    二元凸函数

    选取函数 f(x,y)=3x2+2xy+4y2f(x, y) = 3x^2 + 2xy + 4y^2,计算 Hessian 矩阵:

    H(f)=(2fx22fxy2fyx2fy2)=(6228)H(f) = \begin{pmatrix}\frac{\partial^2 f}{\partial x^2} & \frac{\partial^2 f}{\partial x \partial y} \\\frac{\partial^2 f}{\partial y \partial x} & \frac{\partial^2 f}{\partial y^2}\end{pmatrix} = \begin{pmatrix}6 & 2 \\2 & 8\end{pmatrix}

    Hessian 矩阵的特征值均为正数,因此海塞矩阵是正定的,因此 f(x,y)=3x2+2xy+4y2f(x, y) = 3x^2 + 2xy + 4y^2 是凸函数并且是严格凸的。

    三元凸函数

    选取函数 g(x,y,z)=2x2+2xy+3y2+2xz+z2g(x, y, z) = 2x^2 + 2xy + 3y^2 + 2xz + z^2,计算 Hessian 矩阵:

    H(g)=(2gx22gxy2gxz2gyx2gy22gyz2gzx2gzy2gz2)=(422260202)H(g) = \begin{pmatrix}\frac{\partial^2 g}{\partial x^2} & \frac{\partial^2 g}{\partial x \partial y} & \frac{\partial^2 g}{\partial x \partial z} \\\frac{\partial^2 g}{\partial y \partial x} & \frac{\partial^2 g}{\partial y^2} & \frac{\partial^2 g}{\partial y \partial z} \\\frac{\partial^2 g}{\partial z \partial x} & \frac{\partial^2 g}{\partial z \partial y} & \frac{\partial^2 g}{\partial z^2}\end{pmatrix} = \begin{pmatrix}4 & 2 & 2 \\2 & 6 & 0 \\2 & 0 & 2\end{pmatrix}

    Hessian 矩阵的特征值均为正数,因此海塞矩阵是正定的,因此 g(x,y,z)=2x2+2xy+3y2+2xz+z2g(x, y, z) = 2x^2 + 2xy + 3y^2 + 2xz + z^2 是凸函数并且是严格凸的。

    当然我们也可以通过计算主子矩阵的行列式来代替计算矩阵的特征值,上述二元同样也可以。例如对于下述对称矩阵:

    A=(210121012)A = \begin{pmatrix} 2 & -1 & 0 \\ -1 & 2 & -1 \\ 0 & -1 & 2 \end{pmatrix}

    我们计算其所有主子矩阵的行列式:

    • 一阶主子矩阵 (2)\begin{pmatrix} 2 \end{pmatrix} 的行列式为 22
    • 二阶主子矩阵 (2112)\begin{pmatrix} 2 & -1 \\ -1 & 2 \end{pmatrix} 的行列式为:

      det(2112)=22(1)(1)=41=3\det \begin{pmatrix} 2 & -1 \\ -1 & 2 \end{pmatrix} = 2 \cdot 2 - (-1) \cdot (-1) = 4 - 1 = 3

    • 三阶主子矩阵即矩阵 AA 本身的行列式:

      det(A)=210121012=4\det(A) = \begin{vmatrix} 2 & -1 & 0 \\ -1 & 2 & -1 \\ 0 & -1 & 2 \end{vmatrix}=4

    由于所有主子矩阵的行列式都大于零,矩阵 AA 是正定的。假如这是一个函数的海塞矩阵,则该函数是凸函数并且是严格凸的。

四、计算 68'

  • 计算点到超平面的距离,转化为等式约束的最优化问题(ch1.3)

    这个应该是很显然的一道题,我们定义目标函数为点 aa 到超平面上点 xx 的距离,约束条件为 xx 在超平面上。求解方法就是构造一个拉格朗日函数,然后利用一阶必要条件求解即可。

  • 线性规划问题中求对偶问题表达式(ch2.3.1)

    直接看 2.3.1 的实战演练即可。

  • 线性规划问题中已知原问题最优解,求解对偶问题最优解(ch2.3.2)(利用互补松弛定理、原问题和对偶问题最优解相等)

    见 ch2.3.2 有详细求解步骤。注意可能试卷中给的是原问题的最优解,需要求解对偶问题的最优解,原理是一样的,把原问题看成对偶问题,对偶问题看成原问题,就和 ch2.3.2 的求解逻辑完全一致了。

  • 0.618 法求精确步长(ch3.2)

    每次缩函数值大的点即可。

  • 最速下降法、牛顿法求解方法及其优缺点,共轭梯度法的优缺点(ch4)(直接给步长)

    同样很显然的一道题,透露说只要迭代一步?总之就那两个迭代公式,况且步长都给了,记一下方向的迭代即可,最速下降法就是负梯度方向,牛顿法就是二阶梯度的逆乘负梯度作为新的方向。至于优缺点,简单记忆一下即可。最速下降法无非程序简单但因为搜索方向是正交的导致收敛速度差,牛顿法虽然收敛速度快了但是存储的内容太多导致计算量变大,开销增加。

    至于共轭梯度法的优缺点,优点就是该法是最速下降法和牛顿法的结合,每次的搜索方向是共轭的,这样就不用存储海塞矩阵并且收敛速度往往比最速下降法更快。主要用于解决正定二次函数的极小化问题。但在解决其余问题时可能会对搜索步长有极高的依赖,一旦搜索步长不够精准会导致整体的精度下降,收敛速度也会下降。

  • 有效集方法解不等式约束的二次规划问题(ch6)(小概率考到,考到就gg,因为不会)

  • 等式约束下的二次罚函数法(ch7)(一个约束函数,一个等式约束条件,共 4 问)

    1. 用拉格朗日乘子法求出最优解 xx^* 和拉格朗日乘子 λ\lambda^*

    2. 写出二次罚函数表达式 PE(x,σ)P_E(x,\sigma),讨论罚因子 σ\sigma 在什么取值范围下可以使海塞矩阵 2PE(x,σ)\nabla^2P_E(x,\sigma) 正定

    3. 求最优解 xx^*(直接计算一阶梯度 \nabla 并用 σ\sigma 表示 xx^* 即可)

    4. σ\sigma \to \infty​ 时求最优解,并判断是否和第一问计算结果一致

    示例:

    等式约束的二次罚函数法

  • 约束最优化问题,约束条件中只有不等式(ch7)(用拉格朗日乘子法,已知有 3 个不等式,且 232^3 个答案中只有一个是合法结果)

    拉格朗日乘子法解一般约束优化问题

很遗憾将这门课学成了面向已知考试题型的过拟合形式。我并不觉得我掌握了多少优化理论的知识,最多称得上知道了优化问题的大致分类和一些基本的优化应用。从我的笔记就可以看出,自第三章开始就没怎么涉及到理论的证明,确实是证不明白🤡。但这门课自下届开始就被取消了hh。。。。你我皆是局内人,祝好。

]]>
+ 机器学习

前言

本博客初稿完成与大二下学期,记录 Machine Learning 相关内容。主要参考 《机器学习与模式识别》· 周志华 · 清华大学出版社 和 南瓜书 《机器学习》(西瓜书)公式详解

1 绪论

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

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

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

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

2 模型评估与选择

2.1 误差与过拟合

误差有以下两种:

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

误差曲线

可以看到,模型复杂度不够就会欠拟合,模型复杂度过高就会过拟合。如何解决欠拟合和过拟合呢?

欠拟合解决方法:

  • 决策树:拓展分支;

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

过拟合解决方法:

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

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

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

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

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

在损失函数中添加正则化到底有什么好处?

  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}

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)2MSE=\frac{1}{m} \sum_{i=1}^m(f(x_i) - y_i)^2

均方根误差

RMSE=1mi=1m(f(xi)yi)2RMSE=\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=1myiR^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 偏差与方差

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

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

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

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 线性模型

本章介绍机器学习模型中的线性模型。基本形式如下:

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

我们将从不同的学习任务展开讲解。主要就是回归任务和分类任务。

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

当然,线性模型的优缺点也很明显。优点在于形式简单、易于建模、高可解释性以及是非线性模型的基础。缺点在于无法解决线性不可分的问题。

3.2 线性回归

对于线性回归模型,如何学习参数 wwbb 使得预测值 f(x)f(x) 与真实值 yy 尽可能的接近?我们引入学习准则:最小化均方误差,也就是所谓的最小二乘估计。直观的理解就是寻找一条直线使得所有的样本点到该直线的距离之和尽可能的小。下面一一介绍不同线性模型的优化算法

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

将当前测试样本属于各个类别的概率之和约束为 11。若共有 nn 个输出,则将第 ii 个输出 xix_i 转化为 [0,1][0,1] 取值范围的公式为:

Pi=exij=1nexjP_i =\frac{e^{x_i}}{\sum_{j = 1}^n e^{x_j}}

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 @@ - 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 个数
  • 动规:完全背包
  • 深搜:表达式树、大根堆、图染色
]]>
@@ -1010,11 +1010,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 分位数
]]>
@@ -1031,11 +1031,11 @@ - SysBasic - - /GPA/4th-term/SysBasic/ + OptMethod + + /GPA/4th-term/OptMethod/ - 计算机系统基础

前言

学科地位:

主讲教师学分配额学科类别
闫文珠3.5专业课

成绩组成:

实验(9次)平时作业期末(闭卷)
20%30%50%

教材情况:

课程名称选用教材版次作者出版社ISBN号
计算机系统基础《计算机系统基础》4袁春风机械工业出版社978-7-111-60489-1

学习资源:

期末考试:

  • 简答(类似于作业)
  • 计算(类似于作业)
  • 综合(尽可能收集老师们的复习资料)

第1章 计算机系统概述

1.1 计算机基本工作原理

冯.诺依曼结构 - 计算机模型

如图,冯诺依曼认为计算机应该由上述五个部分组成,相互协作完成任务:

  1. 存储器:存储数据和指令
  2. 运算器:可以进行四则运算和逻辑运算
  3. 控制器:自动取指令来执行
  4. 输入设备:用户输入
  5. 输出设备:系统输出

现代计算机结构模型

现代计算机结构模型

1.2 程序的开发与运行

pass

1.3 计算机系统的层次结构

计算机系统的层次结构

1.4 计算机系统性能评价

一个完整的计算机系统由软件和硬件共同组成,而硬件的性能对其起决定性作用,但是硬件的性能检测和评价比较困难。本目介绍综合性测试、评价硬件性能的方法。

1.4.1 计算机性能的定义

考量一个计算机性能的基本指标有两个,分别为吞吐率(throughput)和响应时间(response time),下面做出相应的理论解释和自己的理解。

理论定义

  • 吞吐率:计算机系统单位时间内完成的工作量。

  • 响应时间:计算机系统完成一个作业从提交开始到作业完成所用的时间。

个人理解:我们以备份文件为例。对于一个大文件,我们希望计算机系统的吞吐率性能足够好,这样处理单文件的能力就会很高。对于很多的小文件,吞吐率高没有什么决定性作用,起决定性作用的是响应时间,我们希望响应时间尽可能的短,这样处理大规模小文件集合的时候就会有优势。那么相应的适用场景也就合理了。

适用场景:对于多媒体应用场景,就相当于大型文件,此时自然希望吞吐率尽可能的高。对于银行、证券交易业务,就相当于大规模小文件集合,我们希望系统的响应时间尽可能的短,这样就可以在单位时间解决更多的小型业务。当然,两者性能均优异自然是最佳选择,对于一些用户体验很重要的应用,比如 ATM、文件服务、Web 服务 等等,就需要两者性能均优。

1.4.2 计算机性能的测试

如果不考虑应用背景而直接比较计算机性能,往往通过程序的执行时间来衡量。下附程序的执行时间组成图:

程序的执行时间(用户感受到的)

我们关注的是用户 CPU 时间。那么如何计算呢?我们引入三个相关计算量:

  1. 时钟周期:即一个时钟脉冲信号持续的时间。由于计算机执行一条指令得过程会被分解为不同的小模块进行,因此我们需要控制每一个小模块的执行过程。引入脉冲信号来控制信号的发出和作用的时间等等。
  2. 时钟频率:单位时间内的时钟周期数。即上述时钟周期的倒数。
  3. CPI(Cycles Per Instruction)
    • 对于一条指令而言:CPI 指的是一条指令所需的时钟周期数
    • 对于一个程序或一台机器而言:CPI 指的是该程序或机器的指令集中所有指令平均执行所需的时钟周期数

计算用户 CPU 时间的公式:

用户 CPU 时间=程序总时钟周期数×时钟周期=程序总时钟周期数÷时钟频率\begin{equation*}\begin{aligned}\text{用户 CPU 时间} &= \text{程序总时钟周期数} \times \text{时钟周期} \\&= \text{程序总时钟周期数} \div \text{时钟频率}\end{aligned}\end{equation*}

补充说明:

  • 显然,用户 CPU 时间与计算机性能成反比
  • 上述三个相关计算量是相互制约的,不存在只提升或下降某一个指标

1.4.3 用指令执行速度进行性能评估

pass

1.4.4 用基准程序进行性能评估

pass

1.4.5 Amdahl 定律

对于系统中某个部分(软件或硬件)进行更新所带来的系统的性能提升,是取决于这部分原本的运行时间占总运行时间,以及这部分升级了多少倍两者共同决定的,用公式表示改进后系统的执行时间

改进后的执行时间=改进部分原本占用的时间改进的倍数+剩余部分占用的时间\text{改进后的执行时间} = \frac{\text{改进部分原本占用的时间}}{\text{改进的倍数}}+\text{剩余部分占用的时间}

当然整体改进倍数也就可以表示为:

整体改进倍数=1改进部分原本占用的时间比例改进的倍数+剩余部分占用的时间比例\text{整体改进倍数} = \frac{1}{ \frac{\text{改进部分原本占用的时间}\textbf{比例}}{\text{改进的倍数}} + \text{剩余部分占用的时间}\textbf{比例} }

1.5 本书的主要内容与组织结构

第一章主要介绍:计算机的基本工作原理、计算机系统的基本组成、程序的开发与执行过程、计算机系统的层次结构以及性能评价的基本概念。

第二章主要介绍:各类数据在计算机中的表示与运算。

第三章主要介绍:高级语言中的过程调用和控制语句所对应的汇编指令序列,以及各类数据结构元素的访问所对应的汇编指令。

第四章主页介绍:如何将多个模块链接起来生成一个可执行的目标文件。

第2章 数据的机器级表示与处理

本章主要从四个方面展开,分别为:数值数据的表示、非数值数据的表示、数据的存储、数据的运算。

2.1 数制和编码

2.1.1 信息的二进制编码

  • 计算机内部所有的信息都采用二进制 01 进行编码

  • 需要编码的机器级数据分为以下两类:

    1. 数值数据:整数(带符号、无符号)、浮点数
    2. 非数值数据:逻辑数、西文字符、汉字字符
  • 理解真值和机器数的概念

2.1.2 进位计数制

进制表示

  1. 二进制:后缀为 B(即 Binary,如 0110B)
  2. 八进制:后缀为 O(即 Octal,如 12673O)
  3. 十进制:后缀为 D(即 Decimal,如 291D;可省略,即 291)
  4. 十六进制:后缀为 H(即 Hexadecimal,如 3F9H;可以前缀 0x 标记,即 0x3F9)

进制转换:一般先将数据转换为二进制,再进行相应转换

  1. R 进制转换为 十 进制
  2. 十进制转换为 R 进制
  3. 二、八、十六进制的相互转换

2.1.3 定点与浮点的表示

定点数:小数点位置约定在固定位置的数

  • 定点小数:小数点总是固定在数的最左端,可以用来表示浮点数的尾数部分
  • 定点整数:小数点总是固定在数的最右端,可以用来表示整数

浮点数:小数点位置约定为可浮动的数。对于任意一个实数 X 都可以表示成下面的形式:

X=(1)S×M×REX = (-1)^{S} \times M \times R^E

  • SS0011 决定当前实数的正负
  • MM 是一个二进制定点小数,称为实数的尾数。有效位数越多,说明精度越高
  • EE 是一个二进制定点整数,称为实数的阶数。决定小数点的位置
  • RR 称为实数的基数。由数制决定

取值范围:假定浮点数的尾数为纯小数即尾数不为零,则浮点数 XX 的绝对值的最小值表示为 0.00...1×R11...10.00...1 \times R^{-11...1},最大值表示为 0.11...1×R11...10.11...1 \times R^{11...1}。假设阶的位数为 mm,尾数的位数为 nn。则浮点数 XX 的绝对值真值的取值范围为:

Rn×R(Rm1)X(1Rn)×RRm1R^{-n} \times R^{-(R^m-1)} \le |X| \le (1-R^{-n}) \times R^{R^m-1}

2.1.4 定点数的编码表示

  1. 原码表示法
  2. 补码表示法
    • 模运算:对一个负数取模=负数\text{对一个负数取模} = \text{模} - |\text{负数}|
    • 补码的定义:[X]=2n+X[X]_{\text{补}}=2^n+X,对于 nn 位的运算系统,可以表示补码的数据范围为 2n1X<2n1-2^{n-1} \le X < 2^{n-1}
  3. 反码表示法
  4. 移码表示法

2.2 整数的表示

2.2.1 无符号整数和带符号整数的表示

无符号整数:所有的二进制位都用来表示数值。比如对于 nn 位的二进制数,无符号整数的数值范围为 [0,2n1][0,2^{n} - 1]

有符号整数:第一位二进制位必须用来进行正负性表示,后 n1n-1 位用来表示数值。同样对于 nn 位的二进制数,有符号整数的数值范围为 [2n1,2n1)[-2^{n-1}, 2^{n-1})

2.2.2 C 语言中的整数及其相互转换

C 语言标准中规定:若运算中同时有无符号和带符号整数,则按无符号整数运算

搞懂这张真值比较表即可:

真值比较表

2.3 浮点数的表示

2.3.1 浮点数的表示范围

我们以 32 位浮点数为例。32 位中:

  • 第 0 位为符号位 (Sign)\text{(Sign)}。1 表示负数,0 表示正数
  • 第 1 - 8 位为阶数位 (Exponent)\text{(Exponent)}。采用移码表示,通常需要加上偏执常数 128
  • 第 9 - 31 位为尾数位 (Fraction)\text{(Fraction)}。规格化尾数的表示形式为 0.1bb...b0.1\text{bb...b}

正数的最大值:0.11...1×211...1=(1224)×21270.11...1 \times 2^{11...1}=(1-2^{-24}) \times 2^{127}

正数的最小值:0.10...0×200...0=21×2128=21290.10...0 \times 2^{00...0} = 2^{-1} \times 2^{-128}=2^{-129}

浮点数的表示范围

2.3.2 浮点数的规格化

分为左规和右规,目的是尽可能多的得到有效位,同时使得浮点数的表示具有唯一性

2.3.3 IEEE 754 浮点数标准

与前面提到的规则略有不同。对于下列两种精度的浮点数:有以下规定:

  1. 对于尾数:隐藏位 1 还是存在,不过现在不是在小数点右边,而是在小数点左边
  2. 对于阶数:同样采用移码的形式,只不过现在的偏执常数不是 2n12^{n-1},而是 2n112^{n-1}-1。因此单精度和双精度浮点数的偏执常数分别为 2811=127,21111=10232^{8-1}-1=127,2^{11-1}-1=1023

IEEE 754 浮点数标准

对于本目,需要掌握 IEEE 754 规格化小数真值之间的转换,转换规则如下:

十进制小数转化为IEEE754的小数

IEEE754小数转化为十进制小数

浮点数的 5 种表示形式:

浮点数的5种表示形式

单精度浮点数的各种极端情况:

单精度浮点数的各种极端情况

2.3.4 C 语言中的浮点数类型

主要掌握强制类型转换。

  • int 到 float 可能丢失有效数字。因为 int 的有效数位比 float 多
  • int、float 到 double 数值不变
  • double、float 到 int 可能丢失有效数字或溢出
  • double 到 float 可能丢失有效数字或溢出

2.4 十进制数的表示

2.4.1 用 ASCII 码字符表示

pass

2.4.2 用 BCD 码表示

pass

2.5 非数值数据的编码表示

2.5.1 逻辑值

1 个 二进制数表示 1 个逻辑值。

2.5.2 西文字符

西文字符集的编码是 ACSII 码,一般是 7 位二进制位 b6b5b4b3b2b1b0b_6b_5b_4b_3b_2b_1b_0 来表示。可能会有第八位 b7b_7,该位往往用来进行奇偶校验。

2.5.3 汉字字符

汉字数量庞大,且是表意形的字符,为了在计算机中表示汉字,我们需要处理三个问题,分别是输入表示、机器表示、输出表示:

  1. 汉字的输入码:用于对输入的中文字符进行编码

  2. 字符集与汉字内码:用于系统内部的存储、查找、传送等处理

  3. 汉字的字模点阵码和轮廓描述:用于显示和打印

概念定义:

  • 图像的基本单位是像素,是真实世界的数字表示

  • 图形的往往是数学或程序算法生成的,是虚构的

2.6 数据的宽度和存储

2.6.1 数据的宽度和单位

计算机中数据的表示宽度及其单位

  • 二进制信息的最小单位:比特(bit)

  • 最小寻址单位:字节(Byte),其中 1Byte = 8 bit

  • 更大的单位表示:字(word),其中 1 word = 2 bit | 4 bit | 8 bit | 16 bit

计算机中数据的运算、存储、传输的部件宽度及其单位

  • 数据通路宽度:字长

2.6.2 数据的存储和排列顺序

对于一个 32 bit 的数 1000 0000 0000 0000 0000 0000 0000 0110 而言,我们定义:

  • 最高有效位(MSB,Most Significant Bit):上述 32 位数的最左边的一位 1

  • 最低有效位(LSB,Least Significant Bit):上述 32 位数的最右边的一位 0

  • 最高有效字节(MSB,Most Significant Byte):上述 32 位数的最左边的一字节 1000 0000

  • 最低有效字节(LSB,Least Significant Byte):上述 32 位数的最右边的一字节 0000 0110

现代计算机都是对字节进行编址方式。由于程序中对每个数据只给定一个地址,那么对于多字节的数据如何根据仅有的一个地址进行 CPU 的内存分配呢?这就是经典的字节排序问题。现在介绍两种内存分配方式:大端方式、小端方式

  • 大端方式:最低有效字节存放在最高位。对应的机器码中最低有效字节在最右端,例如数值 0x0000001E 在大端方式下存储为 00 00 00 1E

  • 小端方式:最低有效字节存放在最低位。对应的机器码中最低有效字节在最左端,例如数值 0x0000001E 在小端方式下存储为 1E 00 00 00

  • 举例说明:对于机器数 0xFFFFFFF6 而言,下面的表示方式中,左侧为小端存储,右侧为大端存储

    左侧为小端存储,右侧为大端存储

2.7 数据的基本运算

学完了基本的数据表示以后,让我们开始学习数据的基本运算。高级语言中涉及到的各种运算,都会被编译成底层的算术运算指令和逻辑运算指令实现,本目要介绍的也就是其中的运算逻辑。

2.7.1 按位运算和逻辑运算

按位运算

|&~^
按位或按位与按位取反按位异或

逻辑运算

||&&!
逻辑或逻辑与逻辑非

2.7.2 左移运算和右移运算

无符号整数采用逻辑移位

  • 左移:高位移出,低位补 0。如果移出为 1,则溢出
  • 右移:高位补 0,低位移出

有符号整数采用算数移位

  • 左移:高位移出,低位补 0。如果左移前后符号位不同,则溢出
  • 右移:高位补符号,低位移出

2.7.3 位扩展运算和位截断运算

扩展操作:在短数向长数转换时

  • 有符号整数采用符号扩展:前方补符号位
  • 无符号整数采用 0 扩展:前方补 0
1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdio.h>

int main() {
short int si = -32768;
unsigned short usi = si;
int i = si;
unsigned ui = usi;

printf("si = \t%hd\t%x\n", si, si);
printf("usi = \t%hu\t%x\n", usi, usi);
printf("i = \t%d\t%x\n", i, i);
printf("ui = \t%u\t%x\n", ui, ui);
}

上述代码在 32 位大端机器上的输出:

1
2
3
4
si  =   -32768  8000
usi = 32768 8000
i = -32768 ffff8000
ui = 32768 8000

分析:

  • siusi 是同一个机器数的不同真值结果,由于数位没有长短的变化,故十六进制表示结果相同
  • i 是有符号整数的位扩展,采用符号扩展,补充符号位 1,故向前补充了 16 个 1
  • ui 是无符号数的位扩展,采用 0 扩展,补充 0,故向前补充了 16 个 0

截断操作:在长数向短数转换时

  • 有符号整数,高位直接丢弃
  • 无符号整数,高位直接丢弃
1
2
3
4
5
6
7
8
9
10
11
#include <stdio.h>

int main() {
int i = 32768;
short si = (short)i;
int j = si;

printf("i = \t%d\t%x\n", i, i);
printf("si = \t%hd\t%x\n", si, si);
printf("j = \t%d\t%x\n", j, j);
}

上述代码在 32 位大端机器上的输出:

1
2
3
i  =    32768   00008000
si = -32768 8000
j = -32768 ffff8000

分析:

  • i 的真值和机器数很显然
  • si 的机器数就是将 i 的机器数截取了后 16 位的结果,在识别为有符号数以后,得到的真值就是最小的负数 -32768
  • j 是有符号整数的位扩展运算,补充符号位 1
  • 可以发现了截断错误,而这种错误编译器一般是不会发现的!

2.7.4 整数加减运算

整数加/减运算部件

部件解读:对于无符号整数和有符号整数而言,都可采用该运算部件进行运算

  • 输入:

    1. 运算数 A:整数 A 的补码 01 序列
    2. 运算数 B:整数 B 的补码 01 序列
    3. 判别数 Sub:A+BSub=0A-BSub=1
  • 输出:

    1. 结果 Sum:表示整数 A+B 的计算结果

    2. 零标志 ZF:表示 A+B 是否为 0

    3. 符号标志 SF:表示计算结果 Sum 的有效范围内的最高位二进制数值

    4. 溢出标志 OF:判断有符号整数的相加结果是否溢出。计算方式:

      • 若 A 与 B’ 首位同号且与 Sum 的首位不相同,则计算溢出,OF = 1
      • 反之则计算未溢出,OF = 0
    5. 进/借位标志 CF:判断无符号整数的相加结果是否溢出。计算方式:

      • 对于加法:此时的判别数 Sub = 0

        • 若有进位,即 Cout = 1,则对于当前加法器表示计算溢出,CF = 1
        • 若没有进位,即 Cout = 0,则没有计算溢出,CF = 0
      • 对于减法:此时的判别数 Sub = 1

        • 若有借位,则 Cout = 0,则对于当前加法器表示计算结果是负数,即计算溢出,CF = 1
        • 若没有借位,则 Cout = 1,则对于当前加法器表示计算结果是正数,即计算未溢出,CF = 0
      • 综上所述:

        CF=SubCout\text{CF} = \text{Sub} \oplus \text{Cout}

对于两个机器数 x = 1000 0110y = 1111 0110

加法:计算 x + y

MUX:{y=ySub=0Adder:{x:1000 0110y=y:1111 0110Sub:0Result:1 0111 1100Signals:{ZF=0SF=0OF=1CF=1Real Value:{signed:124(134+24628)unsigned:124(134+24628)\begin{aligned}&\text{MUX}:\begin{cases}y' &= y \\\text{Sub} &= 0\end{cases}\\&\text{Adder}:\begin{cases}x: & 1000\ 0110 \\y'=y: & 1111\ 0110 \\\text{Sub}: & 0\end{cases}\\&\text{Result}: 1 \ 0111\ 1100\\&\text{Signals}:\begin{cases}\text{ZF} = 0 \\\text{SF} = 0 \\\text{OF} = 1 \\\text{CF} = 1\end{cases}\\&\text{Real Value}:\begin{cases}\text{signed}: &124(134+246-2^8)\\\text{unsigned}: &124(134+246-2^8)\end{cases}\end{aligned}

减法:计算 x - y

MUX:{y=ySub=1Adder:{x:1000 0110y=y:0000 1001Sub:1Result:0 1001 0000Signals:{ZF=0SF=0OF=0CF=1Real Value:{signed:112unsigned:144(134246+28)\begin{aligned}&\text{MUX}:\begin{cases}y' &= \overline{y} \\\text{Sub} &= 1\end{cases}\\&\text{Adder}:\begin{cases}x: & 1000\ 0110 \\y'=\overline{y}: & 0000\ 1001 \\\text{Sub}: & 1\end{cases}\\&\text{Result}: 0 \ 1001\ 0000\\&\text{Signals}:\begin{cases}\text{ZF} = 0 \\\text{SF} = 0 \\\text{OF} = 0 \\\text{CF} = 1\end{cases}\\&\text{Real Value}:\begin{cases}\text{signed}: &-112 \\\text{unsigned}: &144(134-246+2^8)\end{cases}\end{aligned}

对于 n=8 的情况而言,数据范围是:

{signed:[2n1,2n1)=[128,127]unsigned:[0,2n)=[0,255]\begin{cases}\text{signed}: &[-2^{n-1},2^{n-1}) &=& [-128,127] \\\text{unsigned}: &[0,2^n) &=& [0,255]\end{cases}

2.7.5 整数乘除运算

整数乘法中。两个 n 位的整数相乘得到一个 2n 位整数,但是一般只保留低 n 位,高 n 位舍弃。判断乘法溢出规则如下:

  • 对于无符号整数乘法:若高 n 位全 0 则没溢出,反之溢出
  • 对于有符号整数乘法:若高 n 位全等于 低 n 位的首尾,则没溢出,反之溢出

整数除法中。两个 n 位整数相除,溢出异常和小数舍去规则如下:

  • 除了最小负数除以 -1 会发生溢出,以及除 0 会发生异常以外,其余情况全部是正常运算
  • 无论是带符号还是无符号,所有产生的小数部分全部舍弃,效果就是向 0 取整

2.7.6 常量的乘除运算

常量乘法运算中。由于直接当做整数进行乘法会导致更高的消耗,因此编译器一般会将其拆分为移位运算与加减运算的综合运算

  • 比如表达式 x * 20 中的 20 会被二进制拼凑分解为 2^4 + 2^2,也就是将 x 左移 4 位的结果与 x 左移 2 位的结果相加

常量除法运算中。同样由于时间开销极大,也是采用移位进行优化,但是与加法略有不同,对于除数是一个 2k2^k 整数的形式时:

  1. 若被除数是无符号数或者有符号正数,直接低位丢弃,高位按照相应的规则补齐
  2. 若被除数是有符号负数,且移出低位时有 1,则需要先将被除数加上 2k12^k-1,再执行第一步的操作

2.7.7 浮点数运算

以浮点数加减为例。

  • 转换:转换为二进制数
  • 对阶:小阶往大阶靠拢
  • 尾数加减
  • 尾数规格化:只能让 1 出现在小数点的左边一位
  • 阶码溢出判断

第3章 程序的转换及机器级表示

上一章节我们学习了数据的机器级表示,本章我们将学习程序的机器级表示。包括我们写的屎山是怎么转化成机器能理解的 01 序列的(程序的转换)以及机器理解东西的规则是什么(机器级表示,以 IA-32 指令集为例)。

程序的转换 大约用下面这个流程图就能概括了:

源代码 预处理 源代码 编译 汇编代码 反汇编汇编 机器代码 链接 机器代码\begin{aligned}\text{源代码 $\xrightarrow[]{\text{预处理}}$ 源代码 $\xrightarrow{\text{编译}}$ 汇编代码 $\xrightleftharpoons[\text{反汇编}]{\text{汇编}}$ 机器代码 $\xrightarrow[]{\text{链接}}$ 机器代码}\end{aligned}

机器级表示 无法直接阅读,只能从更高层面的汇编来理解。从上图可以看出,汇编代码可以从「源代码编译」而来,也可以从「机器代码反汇编」而来。本章主要就是按 AT&T\text{AT\&T} 格式对上述两种来源的汇编代码进行分析和理解。

本章内容梗概:

  • 3.1 节:概述高级语言到汇编语言到机器语言的转换;
  • 3.2 节:概述 IA-32 指令集的三个主要方面,分别为 数据类型及其存储格式寄存器存取数据的逻辑机器指令的汇编格式,为后续小节的详细展开做铺垫;
  • 3.3 节:详细展开 机器指令的汇编格式
  • 3.4、3.5 节:以 C 语言为高级语言示例,详细展开 数据类型及其存储格式机器指令的汇编格式
  • 3.6 节:分析异常情况的汇编表示和内部逻辑。

其实很多时候并非写代码才会让电脑起反应,然后根据翻译得到的机器指令做事,我们随便碰些什么都对应很多的指令。下图是我对 读、写、存文件 的可视化展示,以此来加强对计算机内部运作机理的宏观理解:

wdf-is-sys

补充其中的几个概念:

  • 硬盘 (SSD/HDD):持久存储设备
  • 内存 (RAM):临时存储设备
  • 中央处理器 (CPU):中央处理单元

下面经常出现的 寄存器 (Register) 就存在于 CPU 中,作为存储数据的媒介而存在,存取效率极高,也因此决定了 CPU 的处理效率和整体系统的性能。

本章我们要学的「指令集」其实是 CPU 运作机理的一个重要部分。它定义了 CPU 理解和执行指令的规则和方法,也是程序员和编译器与 CPU 交互的接口。

3.1 程序转换概述

3.1.1 机器指令及汇编指令

计算机只认识机器指令,但是机器指令只是一个 01 位串,可读性很差,因此引入汇编指令用于助记。由机器指令组成的程序叫机器语言程序,由汇编指令组成的程序叫汇编语言程序。机器语言程序和汇编语言程序都是机器级程序。将汇编语言转化为机器语言的程序叫汇编程序,将机器语言转化为汇编语言的程序叫反汇编程序

但是汇编语言程序还是很抽象,因此引入高级语言程序。现在我们要解决的是如何将高级语言程序转化为机器语言程序。至于如何转化能够使得时间、空间都最优,这是编译优化要解决的事,本课程不予讨论。

3.1.2 指令集体系结构

回看 1.3 节可以发现,指令集体系结构 (Instruction Set Architecture,简称 ISA)\text{(Instruction Set Architecture,简称 ISA)} 是连接计算机硬件和软件之间的桥梁,将物理上的计算机硬件抽象成了逻辑上的虚拟计算机,称为机器语言级虚拟机。

ISA 定义了机器语言级虚拟机的所有功能,是一套 与 CPU 交互 的规则。

3.1.3 生成机器代码的过程

本目介绍高级语言转化为机器语言的过程,以 C 语言该高级语言为例。假设当前有一个 C 语言文件 main.c,利用 gcc 工具可以实现。

  1. 预处理:在源程序中插入所有用 #include 命令指定的文件和用 #define 声明指定的宏。生成的 main.i文本文件

    1
    gcc -E main.c -o main.i
  2. 编译:将预处理后的源程序编译成汇编语言程序。生成的 main.s文本文件

    1
    gcc -S main.i -o main.s
  3. 汇编:通过汇编程序将编译出来的汇编语言程序转化为可重定位的机器语言程序。生成的 main.o二进制文件

    1
    gcc -c main.s -o main.o
  4. 链接:将多个可重定位的机器语言程序以及库程序链接为可执行文件

    1
    gcc main.o -o main

上述第三步汇编出来的二进制文件 main.o 是不可读的,但是我们可以通过将其反汇编成文本文件,进而可读。可以采用 objdump 工具,通过 -d 参数命令将其反汇编为由汇编语言构成的文本文件,命令如下:

1
objdump -d main

需要补充的一点是:本书中的汇编语言程序的格式采用 AT&T\text{AT\&T},这也是 gcc 和 objdump 使用的默认汇编格式。

3.2 IA-32 指令系统概述

3.2.1 数据类型及其格式

IA-32 支持的数据类型及格式与 C 语言之间的对应关系:

C语言声明Intel操作数类型汇编指令长度后缀存储长度(位)
(unsigned) char整数/字节bb8
(unsigned) short整数/字ww16
(unsigned) int整数/双字ll32
(unsigned) long int整数/双字ll32
(unsigned) long long int--2*32
char *整数/双字ll32
float单精度浮点数ss32
double双精度浮点数ll64
long double扩展精度浮点数tt80/96

3.2.2 寄存器组织和寻址方式

寄存器组织:共有三大类

寄存器组织

  • 8 个通用寄存器,其中

    • EAX, EBX, ECX, EDX 均为 32 位寄存器
    • AX, BX, CX, DX 均为 16 位寄存器
    • AH, BH, CH, DH 均为高 8 位寄存器
    • AL, BL, CL, DL 均为低 8 位寄存器
  • 2 个专用寄存器

  • 6 个段寄存器

寻址方式:

  1. 立即寻址:指令直接给出操作数。准确的说不用寻址了;
  2. 寄存器寻址:指定寄存器 R 的内容为操作数。在寄存器中找数据;
  3. 存储器寻址:基址+变址×比例因子+偏移地址。在内存或者硬盘中找数据。

存储器寻址示例 - AT&T格式汇编

3.2.3 机器指令格式

正如上面所言,机器指令本质上就是一个人为定义的含有某些意义的 01 序列,查询相应的表可以得知对应的汇编语言格式。不需要记忆多少东西,遇到不懂的直接查表即可。

举个例子。下方表格展示了一个经典的转换,怎么转换不重要,重要的是能理解对应汇编语言的逻辑:

机器语言(16 进制格式)汇编语言(AT&T 格式)
C744 2404 0100 0000movl $0x1, 0x4 (%esp)

3.3 IA-32 常用指令类型及其操作

3.3.1 传送指令

符号辨析:R\text{R}Register\text{Register} 表示取寄存器操作数,M\text{M}Memory\text{Memory} 表示取存储器操作数,mov\text{mov}Move\text{Move} 用于数据传送,lea\text{lea}Load Effective Address\text{Load Effective Address} 用于地址计算。

数据传送语句:mov 0x8(%ebp,%edx,4), %eax 实现的功能为 R[eax] = M[R[ebp] + R[edx] * 4 + 8]。表示将计算出来的地址 R[ebp] + R[edx] * 4 + 8 对应的在存储器中的值 M[R[ebp] + R[edx] * 4 + 8] 赋给寄存器 %eax,此时寄存器中存储的信息为操作数的 R[eax]

地址计算语句:lea 0x8(%ebp,%edx,4), %eax 实现的功能为 R[eax] = R[ebp] + R[edx] * 4 + 8。表示将计算出来的地址 R[ebp] + R[edx] * 4 + 8 赋给寄存器 %eax,此时寄存器中存储的信息为操作数的地址 R[eax]

3.3.2 定点算数运算指令

加减乘除分别为:add, sub, mul, div,都是将「源地址」的运算到「目的地址」,即左边对应的值运算到右边对应的值。可以记忆为右边的数 +=, -=, *=, /=

3.3.3 按位运算指令

常用的有按位运算,以及算数、逻辑的左移和右移。按位运算中有一个用于判定单个数情况的指令为 testtest 不修改右边对应的值,其余都遵循上述「左的运算到右进而改变右」的规则。

移位指令大约有:shl, sal, shr, sar,分别为逻辑左移、算数左移、逻辑右移、算数右移。

3.3.4 控制转移指令

常用的有无条件转移,对应 C 语言中的 goto 关键字,汇编表示为 jmp 等。以及有条件转移,比如 je, jne 等,取决于逻辑运算的结果。转移的位置统称为「目标地址」。

3.4 C 语言程序的机器级表示​

用任何汇编语言或高级语言编写的源程序都必须翻译(汇编、解释或编译)成以指令形式表示的机器语言,才能在计算机上运行。本节高级语言选用 C 语言,机器语言选用 IA-32 指令系统,简单介绍高级语言转化为机器语言的过程。

3.4.1 过程调用的机器级表示​

CALL 调用指令:在执行调用过程之前需要将返回地址压入栈

RET 返回指令:在返回调用过程之前从栈中取出返回地址

过程调用的执行步骤(记调用者为 P,被调用者为 Q)

步骤过程
P 将参数存储到 Q 可以访问到的地方按参数列表从右往左依次压入栈
P 保存返回地址(即调用函数的下一个语句)并将控制权给 P将返回地址入栈并执行 CALL 指令
Q 保存 P 的现场并为自己的非静态变量分配空间一般由调用与被调用过程共同保存通用寄存器中的现场
执行 Q 的函数体
Q 恢复 P 的现场并释放局部变量空间恢复现场并释放局部变量空间
Q 取出返回地址并将控制权给 P通过 RET 指令

3.4.2 选择语句的机器级表示

主要由常用指令组合而成,相比于过程调用更加简单。

3.4.3 循环语句的机器级表示

同选择语句。

3.5 复杂数据类型的分配和访问

3.5.1 数组的分配与访问

采用相对寻址,大一学习 C 语言的数组时就学过相关概念了,关键有两点:

  • 知道数组存的是什么类型的数据(进而知道每一个元素占用多少字节)
  • 知道数组首地址。以二维数组为例,运算公式就是 &a + (typeof a) * (i * M) + j,其中 typeof a 就表示单个元素的字节数,i, j 就表示下标索引,M 就表示行数(默认按行优先规则)

3.5.2 结构体数据的分配与访问

采用首地址+偏移的逻辑分配与访问数据,与数组类似。同时,一个结构体存储所有字段的数据。

3.5.3 联合体数据的分配与访问

同样采用首地址+偏移的方式分配和访问数据。一个联合体在同一时刻只存储一种数据类型。即相同的位序列可以在不同时刻存储不同的数据类型变量。

3.5.4 数据的对齐

对于 32 位 OS 而言,存储器一行存储 32 位共 4 个字节,计算机系统一次访问存储空间只能读写固定长度的位。假定最多可以读写 64 位,则称为 8 字节宽的存储器机制。一般采用按边界对齐的对齐策略,尽管这样会浪费一些存储空间,但是有利于数据的快速访问。

一、对齐实例。对于 5 个变量 int a; short b; double c; char d; short e;

按边界对齐:

按边界对齐

边界不对齐

边界不对齐

二、结构体实例。全部按 4 字节对齐,末尾可能有插空

1
2
3
4
5
6
struct A {
int i;
short si;
char c;
double d;
};

1

1
2
3
4
5
6
struct B {
int i;
short si;
double d;
char c;
};

2

3.6 越界访问和缓冲区溢出

越界访问和缓冲区溢出其实是一个东西,只不过缓冲区溢出是单独针对栈空间中对局部数组进行了越界访问。一般而言的越界访问往往表示错误访问了栈空间中的返回地址导致程序出现意想不到的错误。

第4章 程序的链接

在编写大型程序时,为了提升效率以及不断复用,往往需要模块化开发,这就需要我们对多个分散的源代码片段进行整合。目前的整合方法是:首先对每一个源代码片段 (*.c)\text{(*.c)} 分开进行编译、汇编生成对应的可重定位目标文件 (*.o 或 *.obj)\text{(*.o 或 *.obj)},然后通过链接器 (linker)\text{(linker)} 将所有的可重定位目标文件与使用到的其他库的可重定位目标文件整合形成最终的可执行文件 (*.exe)\text{(*.exe)}。其中库文件包含静态库 archive 文件 (*.a)\text{(*.a)} 和动态共享库 shared object 文件 (*.so)(\text{*.so})

本章首先通过简单介绍源代码向可执行文件转换的过程,引出符号解析和重定位的粗略含义。然后详细介绍 UNIX 系统中可重定位目标文件和可执行文件的 ELF 格式。最后详细介绍可重定位目标文件中关于符号定义和符号引用到底是如何解析的,以及链接生成可执行文件的过程中,这些符号又是如何重定位到可执行文件中的。

4.1 编译、汇编和静态链接

模块化的好处:

  • 便于维护与共享。一个地方出问题了修改后重新编译即可,共享也很好理解。
  • 时间效率高。原因同上,不需要全部修改,单点修改即可解决。

4.2 目标文件格式

4.2.1 ELF 目标文件格式

Executable and Linkable Format,简称 ELF。是 UNIX 系统下对可重定位目标文件 (*.o)\text{(*.o)}、可执行目标文件 (*.exe)\text{(*.exe)} 和共享库文件 *.so\text{*.so} 的规范化格式,本目只介绍前两者的格式信息。

4.2.2 可重定位目标文件格式

(*.o 或 *.obj)\text{(*.o 或 *.obj)} 文件。

4.2.3 可执行目标文件格式

(*.exe)\text{(*.exe)} 文件。

4.3 符号解析

4.3.1 符号和符号表

每一个可重定位文件 demo.o 都有一个符号表用来记录本模块的符号信息

4.3.2 符号解析

符号解析的目的:将本模块中引用的符号与某个目标模块中的定义符号建立关联

建立关联的原理:每个定义符号在代码段或数据段中都被分配了存储空间,将引用符号与定义符号建立关联后,就可在重定位时将引用符号的地址重定位为相关联的定义符号的地址

链接器符号:

  • Global symbols(模块内部定义的全局符号)
  • External symbols(外部定义的全局符号)
  • Local symbols(本模块的局部符号)

(1)全局符号的强、弱特性

  • 强符号:函数的定义、已初始化的全局变量
  • 弱符号:函数的声明、未初始化的全局变量

符号解析定义的规则:由于每个符号仅占一处存储空间,因此有以下规则

  • 强符号不能多次定义
  • 若一个符号被定义为一次强符号和多次弱符号,则按强定义为准
  • 若有多个弱符号定义,则任选其中一个

(2)符号解析过程

4.3.3 与静态库的链接

按照符号引用顺序进行静态链接。引用在前,定义在后。

4.4 重定位

只关心全局变量和静态模块变量,对于所有的局部变量全部忽略。

4.4.1 重定位信息

4.4.2 重定位过程

(一)相对重定位方式 R_386_PC32

根据相对位置进行简单的加减运算得出相对的虚拟地址信息。

(二)绝对重定位方式 R_386_32

直接给出引用信息的虚拟内存地址。


至此本课程介绍的内容已全部结束,但其实还有动态链接值得玩味,我大致梳理一下脉络。所谓的静态库解决了代码复用的问题,但也有不足之处,假如磁盘中成千上万个可执行文件都引用了静态库中的某个文件,那么显然就会造成大量存储空间的浪费。同时,假如这些可执行文件同时运行,那么内存中又会读入上述被引用文件的大量重复副本。无论是存储空间还是内存,都是极大的浪费,而动态共享库很好的解决了这个问题,用到哪个库文件直接动态调用不就好了!除此之外,假如我需要维护升级某个静态库文件,那么那些引用该静态库文件的程序都需要重新链接进行更新,这显然是及其不方便且容易出错的。

]]>
+ 最优化方法

前言

学科地位:

主讲教师学分配额学科类别
王启春4专业课

成绩组成:

平时(作业+考勤)期中(大作业)期末(闭卷)
20%30%50%

教材情况:

课程名称选用教材版次作者出版社ISBN号
最优化算法《最优化方法》2孙文瑜高等教育出版社978-7-04-029763-8

第一章 基本概念

1.1 最优化问题简介

本目主要讲解最优化问题的一些分类,下附脑图(由 Xmind 软件制作):

分类脑图

1.2 凸集和凸函数

由于本书介绍的最优化求解方法一般只适用于求解局部最优解,那么如何确定全局最优解呢?以及如何确定唯一的全局最优解呢?本目揭晓答案:

(唯一)全局最优解=局部最优解+(严格)凸目标函数+凸可行域(\text{唯一})\text{全局最优解}=\text{局部最优解}+(\text{严格})\text{凸目标函数}+\text{凸可行域}

其中凸可行域包含于凸集,凸目标函数包含于凸函数,凸目标函数+凸可行域的问题称为凸规划问题。因此本目将分别介绍凸集、凸函数和凸规划三个概念。

1.2.1 凸集

凸集的定义:若集合中任意两点的线性组合都包含于集合,则称该集合为凸集。符号化即:

x,yD  λ[0,1]s.t.λx+(1λ)yD\begin{aligned}\forall \quad x,y \in D \ \land \ \lambda \in [0,1] \\s.t. \quad \lambda x+(1-\lambda)y \in D \\\end{aligned}

凸集的性质:设 D1,D2RnD_1,D_2 \subset R^n 均为凸集(下列 x,yx,y 均表示向量),则:

  1. 两凸集的交 D1D2={x  xD1xD2}D_1 \cap D_2 = \{x\ |\ x \in D_1 \land x \in D_2\} 是凸集
  2. 两凸集的和 D1+D2={x,y  xD1,yD2}D_1 + D_2 = \{x,y\ |\ x \in D_1 , y \in D_2\} 是凸集
  3. 两凸集的差 D1D2={x,y  xD1,yD2}D_1 - D_2 = \{x,y\ |\ x \in D_1 , y \in D_2\} 是凸集
  4. 对于任意非零实数 α\alpha,集合 αD1={αx  xD1}\alpha D_1 = \{ \alpha x \ |\ x \in D_1 \}​ 是凸集

凸集的性质证明

凸集的应用

  1. 刻画可行域

    • 凸组合定义

      设 x(1),x(2),,x(p)Rn,且 i=1pαi=1(ai0)s.t.x=α1x1+α2x2++αpxp则称 x 为向量 x(1),x(2),,x(p) 的凸组合\begin{aligned}\text{设}\ x^{(1)},x^{(2)}, \cdots, x^{(p)} \in R^n ,\text{且}\ \sum_{i=1}^p \alpha_i = 1(a_i \ge 0) \\s.t. \quad x = \alpha_1x^1 + \alpha_2x^2 + \cdots + \alpha_px^p \\\text{则称}\ x\ \text{为向量}\ x^{(1)},x^{(2)}, \cdots, x^{(p)}\ \text{的凸组合}\end{aligned}

    • 凸组合定理:DRnD \in R^n 是凸集的充分必要条件是 DD 中任取 mm 个点 xi(1,2,m)x^i(1,2,\cdots m) 的凸组合任属于 DD,即:

      i=1mαixiD(αi0(i=1,2,,m),i=1mαi=1)\sum_{i=1}^m \alpha_ix_i \in D\left( \alpha_i \ge 0(i=1,2,\cdots,m),\sum_{i=1}^m \alpha_i = 1 \right)

      凸组合定理证明

  2. 分析最优解的最优性条件

    • 超平面定义(凸集分离定理):设 D1,D2RnD_1,D_2 \subset R^n 为两非空凸集,若存在非零向量 αRn\alpha \in R^n 和实数 β\beta,使得

      D1H+={xRn  αTxβ}D2H={xRn  αTxβ}\begin{aligned}D_1 \subset H^+ = \{ x \in R^n \ | \ \alpha^T x \ge \beta\} \\D_2 \subset H^- = \{ x \in R^n \ | \ \alpha^T x \le \beta\}\end{aligned}

      则称超平面 H={xRn  αTx=β}H = \{ x \in R^n \ | \ \alpha^Tx=\beta \} 分离集合 D1D_1D2D_2严格分离时上述不等式无法取等

    • 投影定理:设 DRnD \in R^n 是非空闭凸集,yRny \in R^nyDy \notin D,则

      (1)存在唯一的点 xD,使得集合D到点y的距离最小(2)xD是点y到集合D的最短距离点的充分必要条件为:xD,<xx,yx>0\begin{align*} (1)& \text{存在唯一的点} \ \overline x \in D,\text{使得集合}D\text{到点} y \text{的距离最小} \\ (2)& \overline x \in D \text{是点} y \text{到集合D的最短距离点的充分必要条件为}:\forall x \in D,<x-\overline x,y - \overline x> \le 0\end{align*}

1.2.2 凸函数

凸函数的定义:设函数 f(x)f(x) 在凸集 DD 上有定义

  • x,yD 和 λ[0,1]\forall x,y \in D\ \text{和}\ \lambda \in [0,1]f(λx+(1λ)y)λf(x)+(1λ)f(y)f(\lambda x + (1-\lambda)y) \le \lambda f(x) + (1-\lambda)f(y),则称 f(x)f(x) 是凸集 DD 上的凸函数
  • x,yD 和 λ(0,1)\forall x,y \in D\ \text{和}\ \lambda \in (0,1)f(λx+(1λ)y)<λf(x)+(1λ)f(y)f(\lambda x + (1-\lambda)y) < \lambda f(x) + (1-\lambda)f(y),则称 f(x)f(x) 是凸集 DD 上的严格凸函数

凸函数的性质:

  1. 如果 ff 是定义在凸集 DD 上的凸函数,实数 α0\alpha \ge 0,则 αf\alpha f 也是凸集 DD 上的凸函数
  2. 如果 f1,f2f_1,f_2 是定义在凸集 DD 上的凸函数,则 f1+f2f_1+f_2 也是凸集 DD 上的凸函数
  3. 如果 fi(x)(i=1,2,,m)f_i(x)(i=1,2,\cdots,m) 是非空凸集 DD 上的凸函数,则 f(x)=max1imfi(x)f(x) = \max_{1 \le i \le m} |f_i(x)| 也是凸集 DD 上的凸函数
  4. 如果 fi(x)(i=1,2,,m)f_i(x)(i=1,2,\cdots,m) 是非空凸集 DD 上的凸函数,则 f(x)=i=1mαifi(x)(αi0)f(x) = \displaystyle \sum_{i=1}^m \alpha_i f_i(x)\quad(\alpha_i \ge 0) 也是凸集 DD​ 上的凸函数

第1、2条

第4条

凸函数的判定定理

  1. 函数值角度:函数 f(x)f(x)RnR^n 上的凸函数的充分必要条件是 x,yRn\forall x,y \in R^n,单变量函数 ϕ(α)=f(x+αy)\phi(\alpha)=f(x + \alpha y) 是关于 α\alpha 的凸函数

    凸函数的判别定理证明 - 函数值角度

  2. 一阶梯度角度:设 f(x)f(x) 是定义在非空开凸集 DD 上的可微函数,则:

    • f(x)f(x)DD 上凸函数的充分必要条件是:f(y)f(x)+f(x)T(yx),x,yDf(y) \ge f(x)+\nabla f(x)^T(y-x), \quad \forall x,y \in D
    • f(x)f(x)DD 上严格凸函数的充分必要条件是:f(y)>f(x)+f(x)T(yx),x,yD,xyf(y) > f(x)+\nabla f(x)^T(y-x), \quad \forall x,y \in D,\quad x \ne y

    无需掌握证明,但是为了便于理解性记忆,可以从二次凸函数进行辅助理解记忆。
    凸函数的判别定理证明 - 一阶导数角度

  3. 二阶梯度角度:设 f(x)f(x) 是定义在非空开凸集 DD 上的二阶可微函数,则:

    • f(x)f(x)DD 上凸函数的充分必要条件是:海塞矩阵正半定
    • f(x)f(x)DD 上严格凸函数的充分必要条件是:海塞矩阵正定?其实不一定,例如 x4x^4 是严格凸函数,但是其海塞矩阵是正半定的并非正定。因此更严谨的定义应该是:海塞矩阵正定的是严格凸函数,严格凸函数的海塞矩阵是正半定的。

1.2.3 凸规划(个人补充)

本目为个人补充内容,用于整合上述 1.2.1 与 1.2.2 内容。我们知道,学习凸集和凸函数的终极目标是为了求解凸规划问题,凸规划问题可以简述为 凸可行域 + 凸目标函数 + 局部最优解 = 全局最优解,那么如何证明这个定理是正确的呢?局部最优解求出来以后,目标函数也确定为凸函数以后,如何确定可行域是凸集呢?下面揭晓:

证明凸规划问题的正确性

  1. 定理:在可行域是凸集,目标函数非严格凸的情况下,局部最优解 xx^*​ 也是全局最优解

    证明(反证法)

  2. 定理:在可行域是凸集,目标函数是严格凸的情况下,局部最优解 xx^* 也是唯一的全局最优解

    证明(反证法)

确定可行域是否为凸集

  1. 定理:若约束条件 ci(x)0c_i(x) \ge 0 中每一个约束函数 ci(x)c_i(x) 都是凹函数,则可行域 FF 是凸集

    证明

  2. 定理:若约束条件 ci(x)0c_i(x) \le 0 中每一个约束函数 ci(x)c_i(x) 都是凸函数,则可行域 FF 是凸集

    证明

  3. 定理:若约束条件中每一个约束函数 ci(x)c_i(x) 都恒等于零,则可行域 FF 是凸集

    证明

1.3 最优性条件

最优性条件是指最优化问题的最优解所必须满足的条件,本目介绍求解无约束最优化问题的一阶必要条件和二阶必要条件,再补充介绍二阶充分条件以及针对凸规划问题的二阶充分必要条件。最后简单介绍求解等式约束最优化问题的拉格朗日乘子法。

1.3.1 下降方向

在开始之前先简单介绍一下「下降方向」这个概念。

  • 下降方向定义:设 f(x)f(x) 为定义在空间 RnR^n 的连续函数,点 xˉRn\bar x \in R^n,若对于方向 sRns \in R^n 存在数 δ>\delta > 0 使

    f(xˉ+αs)<f(xˉ),α(0,δ)f(\bar x+\alpha s) < f(\bar x),\quad \forall \alpha \in (0,\delta)

    成立,则称 ssf(x)f(x)xˉ\bar x 处的一个下降方向。在 点 xˉ\bar x 处的所有下降方向的全体记为 D(xˉ)D(\bar x)

  • 下降方向定理:设函数 f(x)f(x) 在点 xˉ\bar x 处连续可微,如存在非零向量 sRns \in R^n 使

    f(xˉ)Ts<0\nabla f(\bar x)^Ts < 0

    成立,则 ssf(x)f(x) 在点 xˉ\bar x 处的一个下降方向

    证明

1.3.2 充分必要条件

f:DRnR1f:D \subset R^n \to R^1 是定义在开集 DD 上的函数,xDx^* \in D 是该函数的局部极小点,f(x)f(x) 的一阶导数 g(x)=f(x)g(x)=\nabla f(x),二阶导数 G(x)=2f(x)G(x)=\nabla ^2f(x)

  • 一阶必要条件。若目标函数在定义域 D 上连续可微,则 xx^* 是局部极小点的一阶必要条件为:

    g(x)=0g(x^*)=0

  • 二阶必要条件。若目标函数在定义域 D 上二阶连续可微,则 xx^* 是局部极小点的二阶必要条件为:

    g(x)=0,G(x)0g(x^*)=0,\quad G(x^*)\ge 0

  • 二阶充分条件。若目标函数在定义域 D 上二阶连续可微,则 xx^*严格局部极小点的二阶充分条件为:

    g(x)=0,G(x)是正定的g(x^*)=0,\quad G(x^*) \text{是正定的}

  • 充分必要条件(凸最优性定理)。在无约束最优化问题中,如果目标函数是凸的,则 xx^* 是全局极小点的充分必要条件是:

    g(x)=0g(x^*)=0

1.3.3 拉格朗日乘子法

如果现在最优化问题不是单纯的无约束最优化问题,而是增设了等式约束的等式约束最优化问题,如何求解呢?我们引入 Lagrange\text{Lagrange} 乘子法,将所有的等式约束都转化到目标函数中得到一个等价的无约束最优化问题,即:

对于这样的等式约束最优化问题:

minf(x)s.t.ci(x)=0,i=1,2,,m.\begin{aligned}\min\quad& f(x)\\\text{s.t.}\quad&c_i(x)=0,i=1,2,\cdots,m.\end{aligned}

引入拉格朗日乘子将其转化为无约束最优化问题,进而利用上述无约束最优化问题的求解策略进行求解:

L(x,λ)=f(x)i=1mλici(x)L(x,\lambda) = f(x) - \sum_{i=1}^m\lambda_ic_i(x)

1.4 最优化方法概述

现实生活中,对于常见问题的建模往往是极其复杂的,为了求得最优解,我们需要对建立的模型进行微分算子的求解。但是问题是,尽管我们知道最优解一定存在于微分算子的数值解中,但是我们往往不能很快的计算出其数值解,因此我们需要采用别的方法进行计算。最常用的方法就是本书介绍的迭代法。

大概就两步:确定初值开始判断,如果符合条件的约束则停止迭代得到最终的解;如果不符合约束则对当前迭代值赋予修正量并继续迭代判断。直到找到最终的解。接下来将对 5 个专有名词进行解释,分别为:初始点的选取、迭代点好坏的判定、收敛速度、迭代的终止条件、修正量 s(k)s^{(k)} 的确定。

1.4.1 初始点的选取

初始点的选取取决于算法的收敛性能。

  • 如果一个算法可以做到全局收敛,则初始点的选取是任意的
  • 如果一个算法只能局部收敛,则初始点的选取往往需要尽可能的接近最优解,而这往往是困难的,因为我们并不知道的最优解是什么。此时可以采用借鉴之前的经验来选择初始点

1.4.2 迭代点好坏的判定

判定一个迭代点的好坏时,我们往往会设计一个评价函数,而评价函数的设计取决于约束问题的种类。

  • 对于无约束最优化问题。我们可以直接将目标函数 f(x)f(x) 设计为评价函数。很显然,如果一个迭代点对应的的函数值比之前点对应的函数值更小,则可以说明当前迭代点由于之前的点
  • 对于有约束最优化问题。此时我们不仅仅需要考虑函数值的大小,也要考虑当前迭代点的可行程度(离可行域的距离)。因此此类最优化问题往往既包含目标函数,也包含约束函数。

1.4.3 收敛速度

若当前算法一定收敛,我们还需要判断收敛的速度,接下来介绍收敛的速度。

假设

  • 设向量序列 {x(k)Rn}\{ x^{(k)} \subset R^n \} 收敛于 xx^*

定义

  1. 误差序列:ek=x(k)xe_k = x^{(k)} - x^*
  2. 收敛率表达式:limkek+1ekr=C\displaystyle \lim_{k \to \infty} \frac{|| e_{k+1} ||}{||e_k||^r} = C,并称序列 {x(k)}\{x^{(k)}\}rr 阶以 CC 为因子收敛于 xx^*

线性收敛

  • 定义:r=1,0<C<1r=1,0 < C<1
  • 性质:CC 越小,算法收敛越快

超线性收敛

  • 定义:r=1,C=0r=1,C=0

  • 定理:r>1r>1​ 时的算法均为超线性收敛算法

    证明

1.4.4 迭代的终止条件

  • 对于收敛速度比较慢的算法:

    根据一阶必要条件,我们知道最优解一定取在微分算子为 0 的向量上,因此我们以下式为终止条件是一种可行的选择

    f(x(k)ϵ||\nabla f(x^{(k)}|| \le \epsilon

    但是问题在于这对收敛速度很快的算法不使用,如下图的无约束最优化问题。已知两个局部最优解分别为 x1,x2x_1^*,x_2^*,迭代的两个解分别为 x1,x2\overline{x}_1,\overline{x}_2,可以看出:尽管 2 号点相对于 1 号点更靠近局部最优解,但是由于 2 号点的梯度更大,明显不如 1 号点更加局部最优,因此利用微分算子作为迭代的终止条件不适用于收敛速度快的算法

    无约束最优化问题

  • 对于超线性收敛的算法:

    一个理想的收敛终止条件为 x(k)xϵ||x^{(k)} - x^*|| \le \epsilon,但是由于最优解 xx^* 是未知的,因此该方案不可行,同理 f(x)f(x^*) 也是未知的,因此 f(x(k))f(x)ϵ||f(x^{(k)}) - f(x^*)|| \le \epsilon 也是不可行的。那么有没有什么替代的方案呢?答案是有的。

    1. 方案一:利用函数解序列进行替代。即以下式作为迭代的终止条件

      x(k+1)x(k)ϵ|| x^{(k+1)} - x^{(k)} || \le \epsilon

      证明

    2. 方案二:利用函数值序列进行替代。即以下式作为迭代的终止条件

      f(x(k+1))f(x(k))ϵ|| f(x^{(k+1)}) - f(x^{(k)}) || \le \epsilon

      证明

  • 一般情况下,对于上述超线性算法的判断收敛的方法,只用其中一种往往不适当。此时一般使用两种方法集成的思路进行判断。

1.4.5 修正量的确定

本书介绍的迭代修正值都是使得当前迭代后的值小于上一个状态的函数值,我们称这类使评价函数值下降的算法为单调下降算法。至于如何得出修正量,我们往往通过求解一个相对简单易于求解的最优化问题(通常成为子问题),来计算获得修正量。

第二章「约束最优化」线性规划

目标函数和约束函数都是线性函数。

2.1 线性规划问题和基本性质

2.1.1 线性规划问题

线性规划问题的一般形式

2.1.2 图解法

仅适用于二元变量的线性规划问题。我们将所有的约束条件全部画到平面直角坐标系中构成一个可行域,然后将目标函数作为一条直线进行平移,直到与可行域初次有交点,则该交点就是最优解对应的点。当然不一定会有交点,一共分为四种情况:

刚好只有一个最优解

有无穷多最优解

有无界解

无可行解 - 可行域为空集

2.1.3 基本性质

  1. 线性规划问题的可行域如果非空,则是一个凸集

    证明

  2. 如果线性规划问题有最优解,那么最优解可在可行域的顶点中确定

  3. 如果可行域有界且可行域只有有限个顶点,则问题的最优解必存在,并在这有限个顶点中确定

  4. 最优解可由最优顶点处的有效约束形成的方程组的解确定

2.1.4 线性规划的标准形

为什么要学习线性规划的标准形?

  • 我们要学习单纯形法来计算多维线性规划的最优解
  • 单纯形法需要线性规划的具有所谓的标准形

线性规划的标准形的定义:

  • 只含有线性等式约束和对变量的非负约束

一般线性规划转化为标准形的方法:

  1. 对于目标函数:需要将取 max\max 通过取相反数转化为取 min\min
  2. 对于约束条件:需要将不等式约束松弛转化为等式约束
    • 对于 \le 的不等式约束,等式左边加上非负新变量,从而转化为等式
    • 对于 \ge 的不等式约数,等式左边减去非负新变量,从而转化为等式
  3. 对于无约束的变量:需要将其转化为两个新变量之差(可正可负),产生了一个新的等式,如果该无约束变量存在于目标函数中,还需要将目标函数中的该变量表示为两个新变量之差

一般线性规划转化为标准形 - 演示

2.1.5 基本可行解

直接说结论:对于标准形的线性规划问题,其基本可行解就是凸集的顶点。

假设系数矩阵 Am,nA_{m, n}。基本可行解 \subset 基本解,而基本解的个数 Cnm\le C_{n}^{m}(因为分块矩阵 Bm×mB_{m \times m} 不一定可逆)。其中基本可行解需要满足解序列元素非负。如果基本变量向量对应的解中含有 0 元素,称其为退化的基本可行解,产生退化的基本可行解的原因是存在可删除的约数条件。

2.1.6 最优解的性质

  • 若可行域有界,则线性规划问题的目标函数一定可以在其可行域的顶点上达到最优。

    证明

  • 有时,目标函数可能在多个顶点处达到最大,这时在这些顶点的凸组合上也达到最大值,这时线性规划问题有无限多个最优解。

2.2 单纯形法

本目主要介绍一种常用的计算线性规划问题可行解的方法:单纯形法。其中,前三条分别介绍单纯性方法计算步骤的理论可行性,第四条具体介绍单纯形法的计算步骤与过程。

2.2.1 初始基可行解的确定

  1. 直接观察:直接看出系数矩阵中含有 m×mm \times m 的单位矩阵,则选择该单位矩阵为初始基
  2. 加松弛变量:当所有的约束条件都是 \le 时,每一个约束条件都加一个非负的松弛变量,则这 mm 个松弛变量的系数就对应了一个 m×mm \times m 的单位矩阵,选择其为初始基即可
  3. 加非负的人工变量:求解方法与常规的线性规划问题不一样,见下述 2.2.3 条。
    • 对于 == 约束,我们在约束条件的左边加上非负人工变量
    • 对于 \ge 约束,我们先在约束条件的左边减去非负松弛变量,再在约束条件的左边加上非负的人工变量

2.2.2 最优性检验

一、当前局面:已经计算出一个基本可行解

1.1 确定线性规划的目标

确定线性规划的目标

1.2 添加松弛变量

添加松弛变量

1.3 用非基变量线性表示基变量

用非基变量线性表示基变量

向量表示

二、最优性检验

最优性检验

  1. 唯一最优解判别定理:对于目标函数求解最大值的情形。若 j[m+1,n]\forall j \in [m+1, n]σj0\sigma_j \le 0 则当前基本可行解 x(k)x^{(k)} 为最优解。

  2. 无穷多最优解判别定理:在满足第一条所有的检验数非正的情况下,j[m+1,n]\exist j \in [m+1, n],有 σj=0\sigma_j=0,则该线性规划问题有无穷多最优解。

    我们可以将任意一个检验数为 0 的非基变量与基变量进行置换得到新的一个基本可行解对应的目标函数值保持不变。于是下述凸组合内的可行解都是最优解

    x(k)s.t.{k[m+1,n]σk=0\begin{aligned}& x^{(k)} \\ & s.t. \begin{cases} k &\in& [m+1, n] \\\sigma_k &=& 0\end{cases}\end{aligned}

  3. 无界解判别定理:pass

  4. 无解判别定理:可行域为空集

2.2.3 计算新的基本可行解

对于常规的线性规划问题(初始基本可行解 x(0)x^{(0)} 可以直接找到)

  1. 向量方程法
    • 确定入基变量:选择目标函数中系数最大的变量作为入基变量,即将其对应的系数列向量与出基变量对应的系数列向量进行置换
    • 确定出基变量:线性表示基方程组以后,利用变量非负的特性找到入基变量刚好取 00 时对应的变量即为出基变量(示例的第二轮迭代中出基变量即为 x5x_5
  2. 系数矩阵法
    • pass

对于添加人工变量的线性规划问题(初始基本可行解 x(0)x^{(0)} 不能直接找到,需要手动构造 x(0)x^{(0)}

  1. 大 M 法:pass
  2. 两阶段法
    • 第一阶段:pass
    • 第二阶段:pass

2.2.4 单纯形表法

向量方程法:

单纯形法的求解步骤 - 1

单纯形法的求解步骤 - 2

单纯形表法:

实战演练

代码验算

2.3 对偶与对偶单纯形法

2.3.1 确定对偶问题

已知原问题的表达式,如何求解对偶问题的表达式?我们需掌握以下对偶转换规则:

转化规则

从上图不难发现,其实就是对线性规划的三个部分进行转换:目标函数、m个约束条件、n个自变量,下面分别进行文字介绍:

  1. 约束条件:从 m 个变为 n 个
    • 常量矩阵中的元素为原问题中自变量的系数
    • 二元关系的变化取决于转化方向
      • max to min:对偶问题约束条件的符号和原问题自变量的符号相同
      • min to max:对偶问题约束条件的符号和原问题自变量的符号相反
    • 线性约束为原问题线性约束的转置
  2. 自变量:从 n 个变为 m 个
    • 二元关系的变化同样取决于转化方向
      • max to min:对偶问题自变量的符号和原问题约束条件的符号相反
      • min to max:对偶问题自变量的符号和原问题约束条件的符号相同
  3. 目标函数:自元个数从 n 个变为 m 个
    • 变元的系数就是原问题中约束条件的常量矩阵对应的元素值

实例

2.3.2 对偶定理

  • 对称性:对偶问题的对偶问题是原问题
  • 无界性:若原问题是无界解,则对偶问题无可行解
  • 对偶定理:若原问题有最优解,那么对偶问题也有最优解,且目标函数值相等
  • 互补松弛性:若原问题最优解为 xx^*,对偶问题的最优解为 yy^*,则有 xys=0,yxs=0x^* y_s=0,y^*x_s=0,其中 xs,ysx_s,y_s 分别为原问题的松弛变量和对偶问题的松弛变量

问题

对于上述问题,我们可以先写出其对偶问题:

对偶问题

显然的对于 y=(45,35)Ty^*=(\frac{4}{5},\frac{3}{5})^T,上述 2,3,4 是取不到等号的,对应的 x=(,0,0,0,)Tx^*=(-,0,0,0,-)^T。还剩两个变量 x1,x5x_1,x_5 通过 1,5 两个不等式取等进行计算:

x1+3x2=42x1+x2=3\begin{aligned}x_1+3x_2=4\\2x_1+x_2=3\end{aligned}

计算可得 x1=1,x5=1x_1=1,x_5=1,于是原问题的最优解 x=(1,0,0,0,1)Tx^*=(1, 0, 0, 0, 1)^T

2.3.3 对偶单纯形法

实战演练

代码验算

第三章 线性搜索

本章介绍最优化问题中迭代解 xk+1=xk+αkdkx_{k+1} = x_k + \alpha_kd_k 基于「线性搜索」的迭代方式。

假设搜索方向 dkd_{k} 是一个定值且一定是下降方向,我们讨论步长因子 αk\alpha_{k} 的计算选择策略。分为两步:

  1. 确定步长的搜索区间
  2. 通过精确 or 不精确算法来搜索合适的步长

本章的内容分布:

  • 3.1 介绍确定初始搜索区间的「进退法」
  • 3.2 介绍缩小搜索区间的「精确线性搜索算法」:0.618 法、斐波那契法、二分法、插值法
  • 3.3 介绍缩小搜索区间的「不精确线性搜索算法」:Armijo 准则、Goldstein 准则、Wolfe 准则

3.1 确定初始搜索区间

确定初始搜索区间 [a,b][a,b],我们利用 进退法

3.2 精确线性搜索算法

在正式开始介绍之前,我们先了解单峰函数定理

  • 定理:对于在区间 [a,b][a,b] 上的一个单峰函数 f(x)f(x)x[a,b]x^* \in [a,b] 是其极小点,x1x_1x2x_2[a,b][a,b] 上的任意两点,且 a<x1<x2<ba<x_1<x_2<b,可以通过比较 f(x1),f(x2)f(x_1),f(x_2) 的值来确定点的保留和舍弃

  • 迭代:

    1. f(x1)f(x2)f(x_1) \ge f(x_2)x[x1,b]x^* \in [x_1,b]

      保留右区间

    2. f(x1)<f(x2)f(x_1) < f(x_2)x[a,x2]x^* \in [a,x_2]

      保留左区间

    3. f(x1)=f(x2)f(x_1) = f(x_2)x[x1,x2]x^* \in [x_1,x_2]

于是迭代的关键就在于如何取点 x1x_1x2x_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
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
class ExactLinearSearch:
def __init__(self,
a: float, b: float, delta: float,
f: Callable[[float], float],
criterion: str="0.618", max_iter=100) -> None:

self.a = a
self.b = b
self.delta = delta
self.f = f
self.max_iter = max_iter

if criterion == "0.618":
new_a, new_b, x_star, count = self._f_goad()
elif criterion == "fibonacci":
new_a, new_b, x_star, count = self._f_fibo()
else: # criterion == "binary"
new_a, new_b, x_star, count = self._f_binary()

fixed = lambda x, acc: round(x, acc)
print(f"算法为:“{criterion}” 法")
print(f"共迭代:{count} 次")
print(f"左边界: {fixed(new_a, 4)}")
print(f"右边界: {fixed(new_b, 4)}")
print(f"最优解: {fixed(x_star, 4)}")
print(f"最优值: {fixed(f(x_star), 6)}\n")


def _f_goad(self) -> Tuple[float, float, float, int]:
a, b, delta, f = self.a, self.b, self.delta, self.f
count = 0

lam = a + 0.382 * (b - a)
mu = b - 0.382 * (b - a)

while count <= self.max_iter:
phi_lam = f(lam)
phi_mu = f(mu)

if phi_lam <= phi_mu:
b = mu
else:
a = lam

lam = a + 0.382 * (b - a)
mu = b - 0.382 * (b - a)

if b - lam <= delta:
return a, b, lam, count
if mu - a <= delta:
return a, b, mu, count

count += 1

return a, b, lam if f(lam) <= f(mu) else mu, count

def _f_fibo(self) -> Tuple[float, float, float, int]:
a, b, delta, f, max_iter = self.a, self.b, self.delta, self.f, self.max_iter
count = None

F = [0.0] * max_iter
F[1] = F[2] = 1
for i in range(3, max_iter):
F[i] = F[i - 1] + F[i - 2]
if F[i] >= (b - a) / delta:
count = i - 2
break

if count == None:
ValueError("区间过大或精度过高导致,找不到合适的迭代次数")

lam, mu = a, b
for i in range(3, count + 1):

lam = a + (1 - F[i - 1] / F[i]) * (b - a)
mu = b - (1 - F[i - 1] / F[i]) * (b - a)

if f(lam) <= f(mu):
b = mu
else:
a = lam

return a, b, lam if f(lam) <= f(mu) else mu, count

def _f_binary(self) -> Tuple[float, float, float, int]:
a, b, delta, f, max_iter = self.a, self.b, self.delta, self.f, self.max_iter
count = None

count = np.ceil(np.log2((b - a) / delta)).astype(int)

if count > max_iter:
ValueError("区间过大或精度过高导致迭代次数过高")

for _ in range(count):
c = (a + b) / 2.0
if f(c) >= 0.0:
b = c
else:
a = c

return a, b, c, count

分别调用 0.618 法、斐波那契法、二分法进行迭代计算:

1
2
3
4
5
6
7
8
a, b, delta = -1, 1, 0.01
f = lambda x: np.exp(-x) + np.exp(x) # 原函数
calc_goad = ExactLinearSearch(a, b, delta, f, criterion="0.618")
calc_fibo = ExactLinearSearch(a, b, delta, f, criterion="fibonacci")

a, b, delta = -3, 6, 0.1
f = lambda x: 2 * x + 2 # 导函数
calc_bina = ExactLinearSearch(a, b, delta, f, criterion="binary")

计算结果为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
算法为:“0.618” 法
共迭代:10 次
左边界: -0.0031
右边界: 0.0069
最优解: 0.0007
最优值: 2.000001

算法为:“fibonacci” 法
共迭代:11 次
左边界: -0.0225
右边界: 0.0
最优解: -0.0139
最优值: 2.000193

算法为:“binary” 法
共迭代:7 次
左边界: -1.0312
右边界: -0.9609
最优解: -0.9609
最优值: 0.078125

3.2.1 0.618 法

基于「函数值」进行选点。

按照黄金分割的比例取点 x1x_1x2x_2,不断迭代判断 f(x1)f(x_1)f(x2)f(x_2) 的值,直到 bkλk<δb_k-\lambda_k<\deltaμkak<δ\mu_k-a_k<\delta 则结束迭代,取最优解 xx^* 为对应的 λk\lambda_kμk\mu_k 即可。

3.2.2 Fibonacci 法

基于「函数值」进行选点。

kk 次迭代的区间长度是上一个区间长度的 FnkFnk+1\frac{F_{n-k}}{F_{n-k+1}}​,即:

bk+1ak+1=FnkFnk+1(bkak)b_{k+1} - a_{k+1} = \frac{F_{n-k}}{F_{n-k+1}} (b_{k} - a_{k})

经过 nn 次迭代后,得到区间 [an,bn][a_n,b_n],且 bnanδb_n-a_n \le \delta,于是可得:

bnan=F1F2(bn1an1)=F1F2F2F3(bn2an2)==F1F2F2F3Fn1Fn(b1a1)=F1Fn(b1a1)δ\begin{aligned}b_n-a_n &= \frac{F_1}{F_2}(b_{n-1}-a_{n-1}) \\&= \frac{F_1}{F_2} \frac{F_2}{F_3}(b_{n-2}-a_{n-2}) \\&= \cdots \\&= \frac{F_1}{F_2} \frac{F_2}{F_3} \cdots \frac{F_{n-1}}{F_n}(b_{1}-a_{1}) \\&= \frac{F_1}{F_n}(b_1-a_1) \le \delta\end{aligned}

在已知上界 δ\delta 的情况下可以直接计算出 nn 的值,于是迭代 nn 次即可得到最终的步长值 λn\lambda_nμn\mu_n

3.2.3 二分法

基于「一阶导数」进行选点。

单调性存在于导数上。极值点左边导数 <0<0,极值点右边导数 >0>0,于是可以进行二分搜索。

3.2.4 插值法

基于「函数值、一阶导数」进行选点。

三点二次插值法

三点二次插值法。给定初始迭代区间 [a0,b0][a_0,b_0] 和初始迭代解 t0t_0。每次在目标函数上取三个点来拟合一个二次函数,通过拟合出来的二次函数的最小值,来更新三个点为 a1,b1,t1a_1,b_1,t_1,直到区间小于上界长度 δ\delta,终止迭代,极小值就是当前状态二次函数的最小值。

那么我们如何求解当前状态二次函数的最小值呢?假定三个已知点为 x1<x2<x3x_1<x_2<x_3,且 f(x1)>f(x2)<f(x3)f(x_1)>f(x_2)<f(x_3),我们需要知道二次函数的三个参数 a,b,ca,b,c,刚好三个已知点可以得到三个方程,从而解出二次函数的三个未知数。

两点二次插值法。只不过少取了一个点,多利用了一个点的导数值来确定二次函数的三个参数罢了,其余迭代过程与三点二次插值完全一致。

3.3 不精确线性搜索算法

精确搜索有时会导致搜索时间过长,尤其是当最优解离当前点还很远时。接下来我们介绍不精确线性搜索方法,在确保每一步的函数值都有充分下降的必要条件下,确保收敛并提升计算效率。真的有这么厉害的算法吗?其实根本逻辑很简单,Armijo 准则限定了搜索方向的上界、Goldstein 准则在前者的基础上又限定了搜索方向的下界(防止过小导致收敛过慢)、Wolfe 准则在前者的基础上又完善了下界的约束(确保不会把可行解排除在搜索区间外)

符号定义:我们在已知上一步解 xkx_k 和下次迭代的下降方向 dkd_k 的基础上,需要寻找合适的 α\alpha。于是唯一变量就是 α\alpha,我们直接定义关于 α\alpha 的一元函数 ϕ(α)=f(xk+αdk)\phi(\alpha) = f(x_k + \alpha d_k)gkg_k 表示梯度。

3.3.1 Armijo 准则

只限定搜索上界,给定初始点 x0x_0、系数 ρ\rho

f(xk+αdk)ρgkTdkα+f(xk)f(x_k + \alpha d_k) \le \rho \cdot g_k^Td_k \cdot \alpha + f(x_k)

3.3.2 Goldstein 准则

又限定了搜索下界,同样给定初始点 x0x_0、系数 ρ\rho

f(xk+αdk)ρgkTdkα+f(xk)f(xk+αdk)(1ρ)gkTdkα+f(xk)\begin{aligned}f(x_k + \alpha d_k) \le &\rho \cdot g_k^Td_k \cdot \alpha + f(x_k)\\f(x_k + \alpha d_k) \ge &(1-\rho) \cdot g_k^Td_k \cdot \alpha + f(x_k)\end{aligned}

3.3.3 Wolfe 准则

完善了搜索下界的约束,即保证新点的梯度 gk+1Tdkg_{k+1}^Td_k 不低于老点梯度 gkTdkg_{k}^Td_kσ\sigma 倍。给定 x0,ρ,σx_0,\rho,\sigma

f(xk+αdk)ρgkTdkα+f(xk)g(xk+αkdk)TdkσgkTdk0<ρ<σ<1\begin{aligned}f(x_k + \alpha d_k) \le &\rho \cdot g_k^Td_k \cdot \alpha + f(x_k)\\g(x_k + \alpha_kd_k)^Td_k \ge& \sigma g_{k}^Td_k\\0 < \rho < \sigma < 1\end{aligned}

参考:

第四章「无约束最优化」

本章介绍无约束函数的最优化算法。其中:

  • 最速下降法基于「一阶梯度」。最基本的方法
  • 牛顿法基于「二阶梯度」。最主要的方法
  • 共轭梯度法基于「一阶梯度」。解大型最优化问题的首选
  • 拟牛顿法基于「函数值和一阶梯度」。尤其是其中的 BFGS 是目前最成功的方法

目标函数 ff、一阶梯度 gg、二阶梯度 GG、初始点 x0x_0

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
def f(x: np.ndarray) -> float:
return (1 - x[0])**2 + 100 * (x[1] - x[0]**2)**2


def g(x: np.ndarray) -> np.ndarray:
grad_x = -2 * (1 - x[0]) - 400 * x[0] * (x[1] - x[0]**2)
grad_y = 200 * (x[1] - x[0]**2)
return np.array([grad_x, grad_y])


def G(x: np.ndarray) -> np.ndarray:
grad_xx = 2 - 400 * x[1] + 1200 * x[0]**2
grad_xy = -400 * x[0]
grad_yx = -400 * x[0]
grad_yy = 200
return np.array([
[grad_xx, grad_xy],
[grad_yx, grad_yy]
])


initial_point = [-1.2, 1]

已知最优点为 x=(1,1)Tx^*=(1, 1)^T,最优解 f(x)=0f(x^*)=0,以书中例题初始点 (1.2,1)T(-1.2,1)^T 为例开始迭代。

一、最速下降法

1
2
3
4
5
6
7
8
9
10
11
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 gradient_descent(initial_point, max_iter=5, eps=1e-6):
x = np.array(initial_point)
points = [x]
gradients = [g(x)]
alphas = []

for _ in range(max_iter):
grad = gradients[-1]

# 搜索方向
direction = -grad

# 步长因子:无法确定准确的步长最小化函数,因此此处采用二分法搜索最优步长
alpha = 1
while f(x + alpha * direction) > f(x):
alpha /= 2

x = x + alpha * direction
points.append(x)
gradients.append(g(x))
alphas.append(alpha)

if np.linalg.norm(grad) < eps:
break

return points, gradients, alphas


points, gradients, alphas = gradient_descent(initial_point, max_iter=100, eps=1e-6)

for i, (point, grad, alpha) in enumerate(zip(points, gradients, [1] + alphas)):
print(f"Iteration {i}:")
print(f" Point = {point}")
print(f" Gradient = {grad}")
print(f" Step Size = {alpha}")
print(f" Direction = {-grad}")
print(f" Function Val= {f(point)}\n")

迭代100次后输出为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Iteration 98:
Point = [0.93432802 0.87236513]
Gradient = [ 0.0942865 -0.12074477]
Step Size = 0.00390625
Direction = [-0.0942865 0.12074477]
Function Val= 0.004349256784446673

Iteration 99:
Point = [0.93414387 0.87260096]
Gradient = [-0.12281587 -0.00476179]
Step Size = 0.001953125
Direction = [0.12281587 0.00476179]
Function Val= 0.004337086557103718

Iteration 100:
Point = [0.93438374 0.87261026]
Gradient = [ 0.04171114 -0.09254423]
Step Size = 0.001953125
Direction = [-0.04171114 0.09254423]
Function Val= 0.004326904052586884

二、牛顿法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
def newton_method(initial_point, max_iter=5, eps=1e-6):
x = np.array(initial_point)
points = [x]
gradients = [g(x)]
Hessians = [G(x)]

for _ in range(max_iter):
grad = gradients[-1]
Hessian = Hessians[-1]

# 搜索方向
direction = np.linalg.inv(Hessian) @ grad

# 步长因子:假定使用固定步长的牛顿法
alpha = 1

x = x - alpha * direction
points.append(x)
gradients.append(g(x))
Hessians.append(G(x))

if np.linalg.norm(grad) < eps:
break

return points, gradients, Hessians


points, gradients, Hessians = newton_method(initial_point, max_iter=50, eps=1e-6)

for i, (point, grad, Hessian) in enumerate(zip(points, gradients, Hessians)):
print(f"Iteration {i}:")
print(f" Point = {point}")
print(f" Gradient = {grad}")
print(f" Hessian = {Hessian}")
print(f" Function Val= {f(point)}\n")

迭代7次即收敛:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Iteration 5:
Point = [0.9999957 0.99999139]
Gradient = [-8.60863343e-06 -2.95985458e-11]
Hessian = [[ 801.99311306 -399.99827826]
[-399.99827826 200. ]]
Function Val= 1.8527397192178054e-11

Iteration 6:
Point = [1. 1.]
Gradient = [ 7.41096051e-09 -3.70548037e-09]
Hessian = [[ 802.00000001 -400. ]
[-400. 200. ]]
Function Val= 3.4326461875363225e-20

Iteration 7:
Point = [1. 1.]
Gradient = [-0. 0.]
Hessian = [[ 802. -400.]
[-400. 200.]]
Function Val= 0.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
def conjugate_gradient(initial_point, max_iter=5, eps=1e-6):
x = np.array(initial_point)
points = [x]
gradients = [g(x)]
directions = [-g(x)]
alphas = []

for i in range(max_iter):
grad = gradients[-1]

# 搜索方向:FR公式
if i == 0:
direction = -grad
else:
beta = np.dot(g(x), g(x)) / np.dot(g(points[-2]), g(points[-2]))
direction = -g(x) + beta * direction

# 步长因子:精确线搜索直接得到闭式解
alpha = -np.dot(grad, direction) / np.dot(direction, G(x) @ direction)


x = x + alpha * direction

directions.append(direction)
alphas.append(alpha)
points.append(x)
gradients.append(g(x))

if np.linalg.norm(grad) < eps:
break

return points, gradients, alphas


points, gradients, alphas = conjugate_gradient(initial_point, max_iter=1000, eps=1e-6)

for i, (point, grad, alpha) in enumerate(zip(points, gradients, alphas)):
print(f"Iteration {i}:")
print(f" Point = {point}")
print(f" Gradient = {grad}")
print(f" Step Size = {alpha}")
print(f" Direction = {-grad}")
print(f" Function Val= {f(point)}\n")

迭代1000次后输出为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Iteration 997:
Point = [0.9999994 0.99999875]
Gradient = [ 1.90794906e-05 -1.01414007e-05]
Step Size = 0.0005161468619784313
Direction = [-1.90794906e-05 1.01414007e-05]
Function Val= 6.191018745155016e-13

Iteration 998:
Point = [0.99999931 0.99999861]
Gradient = [ 5.43686111e-06 -3.40374227e-06]
Step Size = 0.0005078748917694624
Direction = [-5.43686111e-06 3.40374227e-06]
Function Val= 4.986125950068217e-13

Iteration 999:
Point = [0.9999993 0.9999986]
Gradient = [ 1.34784246e-06 -1.36924747e-06]
Step Size = 0.0005356250138048412
Direction = [-1.34784246e-06 1.36924747e-06]
Function Val= 4.881643528976312e-13

四、拟牛顿法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
def bfgs(initial_point, max_iter=5, eps=1e-6):
x = np.array(initial_point)
points = [x]
gradients = [g(x)]
B = G(x)

for _ in range(max_iter):
grad = gradients[-1]

# 迭代公式
x = x - np.linalg.inv(B) @ grad

# 更新 B 矩阵
s = x - points[-1]
y = g(x) - gradients[-1]
B = B + np.outer(y, y) / (s @ y) - (B @ np.outer(s, s) @ B) / (s @ B @ s)

points.append(x)
gradients.append(g(x))

if np.linalg.norm(grad) < eps:
break

return points, gradients


points, gradients = bfgs(initial_point, max_iter=1000, eps=1e-6)

for i, (point, grad) in enumerate(zip(points, gradients)):
print(f"Iteration {i}:")
print(f" Point = {point}")
print(f" Gradient = {grad}")
print(f" Function Val= {f(point)}\n")

迭代 78 次收敛:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Iteration 76:
Point = [1.00000075 0.99999921]
Gradient = [ 0.000918 -0.00045825]
Function Val= 5.255482679080297e-10

Iteration 77:
Point = [1.00000006 1.00000012]
Gradient = [ 6.46055099e-07 -2.63592925e-07]
Function Val= 3.7061757712619374e-15

Iteration 78:
Point = [1. 1.]
Gradient = [6.75185684e-10 4.68913797e-10]
Function Val= 6.510026595267928e-19

4.1 最速下降法

放一张生动的图:

最速下降法 - 迭代示意图

迭代公式:

xk+1=xkαkf(xk)x_{k+1} = x_k - \alpha_k \nabla f(x_k)

每次迭代时,下降方向 dkd_k 采用当前解 xkx_k 处的负梯度方向 f(xk)- \nabla f(x_k)步长因子 αk\alpha_k 采用精确线性搜索算法的计算结果。

易证相邻迭代解 xk,xk+1x_k,x_{k+1} 的方向 dk,dk+1d_k,d_{k+1} 是正交的:由于 ϕ(α)=f(xk+αdk)\phi(\alpha) = f(x_k + \alpha d_k),在采用线搜索找最优步长时,步长的搜索结果 αk\alpha_k 即为使得 ϕ(α)=0\phi'(\alpha)=0 的解,于是可得 0=ϕ(α)=ϕ(αk)=f(xk+αkdk)dk=dk+1Tdk0=\phi'(\alpha) = \phi'(\alpha_k) = \nabla f(x_k+\alpha_k d_k)d_k = -d_{k+1}^T \cdot d_k,即 dk+1Tdk=0d_{k+1}^T \cdot d_k = 0。如图:

相邻迭代解的方向正交

也正因为搜索方向正交的特性导致最速下降法的收敛速度往往不尽如人意。优点在于程序设计简单并且计算和存储量都不大,以及对初始点的要求不高。

4.2 牛顿法

放一张生动的图:

牛顿法 - 迭代示意图

注:本图演示的是求解一维函数零点的迭代过程,需要使得 f(x)=0f(x)=0,因此比值式为原函数除以导函数。后续介绍的是极小化函数的过程,需要使得 f(x)=0f'(x)=0,因此比值式为一阶导数除以二阶导数,高维就是二阶梯度的逆乘以一阶梯度。

迭代公式:

xk+1=xk2f(xk)1f(xk)x_{k+1} = x_k - \nabla^2f(x_k)^{-1}\nabla f(x_k)

牛顿法相较于最速下降法有了更快的收敛速度,但是由于需要计算和存储海塞矩阵导致计算量增加并且有些目标函数可能根本求不出二阶梯度。同时牛顿法对于初始迭代点的选择比最速下降法要苛刻的多。

4.3 共轭梯度法

我们利用共轭梯度法解决「正定二次函数」的极小化问题。由于最速下降法中相邻迭代点的方向是正交的导致搜索效率下降,牛顿法又由于需要计算和存储海塞矩阵导致存储开销过大,共轭梯度法的核心思想是相邻两个迭代点的搜索方向是关于正定二次型的正定阵正交的。这样既保证了迭代收敛的速度也避免了计算存储海塞矩阵的开销。美中不足的是当共轭梯度法解决其他问题是往往会出现对线搜索的过度依赖,一旦线搜索变差会导致整个迭代过程精度变差。

概念补充:

  1. 共轭:xxyy 共轭当且仅当 xTGy=0x^TGy=0,其中 G 为对称正定阵
  2. 正定二次:f(x)=12xTGxbTx+cf(x) =\frac{1}{2} x^TGx - b^Tx + c

公式推导:

  1. 首先给定初始迭代点 x0x_0,收敛阈值 ϵ\epsilon,迭代公式是不变的:xk+1=xk+αkdkx_{k+1} = x_k + \alpha_k d_k,关键在于计算每一次迭代过程中的步长因子 αk\alpha_k​ 和搜索方向 dkd_k

  2. 确定步长因子 αk\alpha_k

    α=minαϕ(α)=minαf(xk+αdk)=minα12(xk+αdk)TG(xk+αdk)bT(xk+αdk)+c=minα12(xkTGxk+2αxkTGdk+α2dKTdk)bTxkbTαdk+c=minα12xkTGxk+αxkTGdk+12α2dkTGdkbTαdk+c\begin{aligned}\alpha &= \min_{\alpha} \phi(\alpha) \\&= \min_{\alpha} f(x_k+\alpha d_k) \\&= \min_{\alpha} \frac{1}{2}(x_k+\alpha d_k)^TG(x_k+\alpha d_k) - b^T(x_k+\alpha d_k) + c \\&= \min_{\alpha} \frac{1}{2} (x_k^TGx_k + 2\alpha x_k^T G d_k + \alpha^2d_K^Td_k) - b^Tx_k - b^T \alpha d_k + c\\&= \min_{\alpha} \frac{1}{2} x_k^TGx_k + \alpha x_k^T G d_k + \frac{1}{2}\alpha^2d_k^T G d_k - b^T \alpha d_k + c\end{aligned}

    由于目标函数是正定二次型,显然可以直接求出步长因子的闭式解:

    dϕ(α)dα=xkTGdk+αdkTGdkbTdk=0\begin{aligned}\frac{d \phi(\alpha)}{d\alpha} &= x_k^T G d_k + \alpha d_k^T G d_k - b^Td_k \\&=0\end{aligned}

    于是可以导出当前的步长因子 αk\alpha_k 的闭式解为:

    αk=(bTxTG)dkdkTGdk=gkTdkdkTGdk\begin{aligned}\alpha_k &= \frac{(b^T - x^TG)d_k}{d_k^TGd_k} \\&= -\frac{g_k^T d_k}{d_k^TGd_k}\end{aligned}

  3. 确定搜索方向 dkd_k

    dk=gk+βdk1d_k = -g_k + \beta d_{k-1}

    可见只需要确定组合系数 β\beta。由于共轭梯度法遵循相邻迭代点的搜索方向共轭,即 dk1TGdk=0d_{k-1}^TGd_k=0,因此对上式两侧同时左乘 dk1TGd_{k-1}^TG,有:

    dk1TGdk=dk1TGgk+βdk1TGdk1=0\begin{aligned}d_{k-1}^TGd_k &= -d_{k-1}^TGg_k + \beta d_{k-1}^TGd_{k-1} \\&= 0\end{aligned}

    于是可得当前的组合系数 β\beta 为:

    β=dk1TGgkdk1TGdk1\beta = \frac{d_{k-1}^TGg_k}{d_{k-1}^TGd_{k-1}}

    上述组合系数 β\beta 的结果是共轭梯度最原始的表达式,后人又进行了变形,没证出来,难崩,直接背吧,给出 FR 的组合系数表达式:

    β=gkTgkgk1Tgk1\begin{aligned}\beta = \frac{g_k^Tg_k}{g_{k-1}^T g_{k-1}}\end{aligned}

    当然了由于初始迭代时没有前一个搜索方向,因此直接用初始点的梯度作为搜索方向,即:

    d0=g0d_0=-g_0

    于是可以导出当前的搜索方向 dkd_k 的闭式解为:

    dk=gk+gkTgkgk1Tgk1dk1d_k = -g_k + \frac{g_k^Tg_k}{g_{k-1}^T g_{k-1}} d_{k-1}

迭代公式:

xk+1=xk+αkdk=xk+(gkTdkdkTGdk)(gk+gkTgkgk1Tgk1dk1)\begin{aligned}x_{k+1} =& x_k + \alpha_k d_k\\=& x_k + (-\frac{g_k^T d_k}{d_k^TGd_k}) (-g_k + \frac{g_k^Tg_k}{g_{k-1}^T g_{k-1}} d_{k-1})\end{aligned}

4.4 拟牛顿法

4.1 和 4.2 介绍的基于一阶梯度和二阶梯度的下降法都可以统一成下面的表达式:

xk+1=xkαkBkf(xk)x_{k+1} = x_k - \alpha_k B_k \nabla f(x_k)

  • 4.1 的最速下降法的步长因子通过精确线搜索获得,海塞矩阵的逆 BkB_k 不存在,可以看做为单位阵 EE
  • 4.2 的牛顿法的步长因子同样可以通过精确线搜索获得,当然也可以设置为定值,海塞矩阵的逆 BkB_k 对应二阶梯度的逆 (2f(xk))1(\nabla^2 f(x_k))^{-1}

前者收敛速度差、后者计算量和存储量大,我们尝试构造一个对称正定阵 BkB_k 来近似代替二阶梯度的逆,即 Bk(2f(xk))1B_k \approx (\nabla^2 f(x_k))^{-1},使得该法具备较快的收敛速度与较少的内存开销。

介绍最著名的 BFGS 拟牛顿法,它的核心思想是每次迭代过程中对其进行快速校正,从而在确保收敛速度的情况下提升计算效率。迭代公式如下:

xk+1=xkBk1gk记: {sk=xk+1xkyk=gk+1gk则: {B0=2f(x0)Bk+1=Bk+ykykTskTykBkskskTBkskTBksk\begin{aligned}&x_{k+1} = x_k - B_k^{-1}g_k \\&\text{记: }\begin{cases}s_k= x_{k+1} - x_k\\y_k=g_{k+1} - g_k\end{cases}\\&\text{则: }\begin{cases}B_0 = \nabla^2f(x_0)\\\displaystyle B_{k+1} = B_k + \frac{y_ky_k^T}{s_k^Ty_k} - \frac{B_ks_ks_k^TB_k}{s_k^TB_ks_k}\end{cases}\end{aligned}

参考:

第五章「无约束最优化」最小二乘

本章继续介绍无约束函数的最优化算法。不过和第四章的区别在于现在的目标函数是二次函数,称为「最小二乘问题」。

所谓的无约束最小二乘问题,本质上是第四章介绍的无约束问题的一个子集,只不过因为使用场景很多所以单独拿出来进行讨论。也正因为使用场景多,学者们针对此类问题设计出了更加高效的最优化算法。

无约束最小二乘问题的形式定义为:

minxRnf(x)=12i=1m[ri(x)]2,mn\begin{aligned}\min_{x\in R^n}f(x)=\frac{1}{2}\sum_{i=1}^m[r_{i}(x)]^2,\quad m\ge n\end{aligned}

其中 ri(x)r_i(x) 称为「残量函数」。本质上最小二乘问题就是寻找一个函数 f(x,αi),(i=1,2,,m)f(x,\alpha_i),(i=1,2,\cdots,m) 来拟合 bb,于是问题就转化为了

mini=1m[ri(x)]2=mini=1m[f(x,αi)bi]2\begin{aligned}\min { \sum_{i=1}^m [r_i(x)]^2 }=\min { \sum_{i=1}^m [f(x,\alpha_i) - b_i]^2 }\end{aligned}

ri(x)r_i(x) 为线性函数时,当前问题为线性最小二乘问题;当 ri(x)r_i(x) 为非线性函数时,当前问题为非线性最小二乘问题。本章将分别讨论这两种最小二乘问题的优化求解策略。

5.1 线性最小二乘

此时可以直接将目标函数写成:

minf(x)=12Axb2=12xTATAxbTAx+12bTb\begin{aligned}\min f(x)&=\frac{1}{2}|| Ax-b ||^2\\&= \frac{1}{2}x^TA^TAx-b^TAx+\frac{1}{2}b^Tb\end{aligned}

利用一阶必要条件可得:

f(x)=ATAxATb=0\begin{aligned}\nabla f(x)&=A^TAx - A^Tb\\&=0\end{aligned}

于是可得最优闭式解:

x=(ATA)1ATbx^*=(A^T A)^{-1}A^Tb

当然 ATAA^TA 并不都是可逆的,并且在数据量足够大时,即使可逆也会让求逆操作即为耗时。针对此问题,提出了线性最小二乘的 QR 正交分解算法。

5.2 非线性最小二乘

同样可以采用第四章学到的各种下降迭代算法,这里引入高斯牛顿法,推导的解的迭代公式为:

xk+1=xk(AkTAk)1AkTrkx^{k+1}=x^k - (A_k^TA_k)^{-1}A_k^Tr_k

其中:

Ak=[r1(xk)r2(xk)rm(xk)],rk=[r1(xk)r2(xk)rm(xk)]\begin{aligned}A_k = \begin{bmatrix}\nabla r_1(x_k)\\\nabla r_2(x_k)\\\vdots \\\nabla r_m(x_k)\end{bmatrix},\quadr_k = \begin{bmatrix}r_1(x_k)\\r_2(x_k)\\\vdots \\r_m(x_k)\end{bmatrix}\\\end{aligned}

参考:

第六章「约束最优化」二次规划

目标函数是二次函数,约束函数是线性函数。一般形式为:

minq(x)=12xTGx+gTxs.t.{aiTx=bi,iSaiTxbi,iM\begin{aligned}&\min \quad q(x) = \frac{1}{2}x^TGx + g^Tx \\&s.t.\quad \begin{cases}a_i^Tx = b_i& ,i\in S\\a_i^Tx \ge b_i& ,i \in M\\\end{cases}\end{aligned}

本章将分别讨论约束函数「含有等式」和「含有不等式」两类二次规划问题。

6.1 等式约束二次规划

我们引入拉格朗日方法 (Lagrange Method, 简称 LM)。此时可以用矩阵表示问题和约束条件,并且不加证明的给出最优解和对应乘子就是满足 KKT 条件下的解。

拉格朗日函数:

L(x,λ)=12xTGx+gTxλT(ATxb)L(x,\lambda) = \frac{1}{2}x^TGx+g^Tx -\lambda^T(A^Tx - b)

一阶必要条件:

L(x,λ)x=Gx+gAλ=0L(x,λ)λ=Axb=0\begin{aligned}\frac{\partial L(x, \lambda) }{\partial x} &= Gx+g-A\lambda = 0 \\\frac{\partial L(x, \lambda) }{\partial \lambda} &= A^x - b = 0\end{aligned}

最优解的矩阵形式:

[GAAT0][xλ]=[gb]\begin{aligned}\begin{bmatrix}G & -A \\-A^T & 0\end{bmatrix}\begin{bmatrix}x^* \\\lambda^*\end{bmatrix}=-\begin{bmatrix}g \\b\end{bmatrix}\end{aligned}

6.2 不等式约束二次规划

我们引入有效集方法 (Active Set Method, 简称 ASM)。首先显然的最优解一定成立于等式约束,或成立于不等式取等。我们可以直接枚举每一种约束条件的组合(所有等式+枚举的不等式,并将不等式看做等式约束),然后判定当前的解是否满足没有选择的不等式约束。这种方法是可行的但是计算量极大。于是有效集方法应运而生。

算法流程:

Primal ASM 算法

算法解析:

  • 计算初始可行点 x(0)x^{(0)},可用线性规划中的大 M 法计算获得
  • 计算前进方向 pkp_k,通过求解等式约束二次规划子问题获得
  • pk=0p_k = \bf{0}​,则计算工作集约束 WkW_k 对应的所有拉格朗日乘子 λi\lambda_i
    • 若所有 λi0\lambda_i \ge 0,则停止迭代,得到最优解 x=x(k)x^*=x^{(k)}
    • 若存在 λi<0\lambda_i < 0,则在工作集中剔除 λi\lambda_i 最小时对应的约束 aja_j,得 Wk+1=Wk{aj}W_{k+1}=W_k \setminus \set{a_j}
  • pk0p_k \ne \bf{0},则计算步长因子 αk\alpha_k,并置 x(k+1)=x(k)+αkpkx^{(k+1)} = x^{(k)} + \alpha_kp_k
    • αk=1\alpha_k=1,则工作集不变,得 Wk+1=WkW_{k+1}=W_k
    • αk<1\alpha_k < 1,则在工作集中添加 αk\alpha_k 最小时对应的约束 aja_j,得 Wk+1=Wk{aj}W_{k+1}=W_k \bigcup \set {a_j}

参考:

第七章「约束最优化」

本章我们简单讨论约束最优化问题中,针对等式约束的「二次罚函数」算法,以及拉格朗日乘子法。

二次罚函数法

对于等式约束问题:

minf(x)s.t.ai(x)=0, i=1,2,...,p\begin{aligned}&\min\quad f(x) \\&s.t.\quad a_i(x) = 0,\ i=1,2,...,p\end{aligned}

我们定义二次罚函数 PE(x,σ)P_E(x, \sigma),其中 σ\sigma 为惩罚因子:

PE(x,σ)=f(x)+12σi=1pai2(x)P_E(x, \sigma) = f(x) + \frac{1}{2} \sigma\sum_{i=1}^p a_i^2(x)

不加证明的给出结论:当惩罚因子 \to \infty 时,目标函数趋于最小值。下面给出算法流程:

等式约束的二次罚函数法

拉格朗日乘子法

目标函数:

目标函数

拉格朗日函数:

拉格朗日函数

KKT 条件:

KKT 条件

在实际求解时,我们只需要罗列上述 KKT 条件中的 (1) 和 (5),另外三个显然不需要再罗列了。我们只需要对 mm 个不等式约束条件对应的乘子进行是否为零的讨论即可,显然需要讨论 2m2^m 次。

后记

下面罗列一下考试题型(考前就透露完了🤣):

一、选择 6 * 2'

  • 严格凸函数的定义(ch1.2)(判断海塞矩阵是否正定即可)

    考虑函数 f(x,y)=x2+y2f(x, y) = x^2 + y^2。我们计算它的 Hessian 矩阵:

    1. 计算一阶导数:

      fx=fx=2xf_x = \frac{\partial f}{\partial x} = 2x

      fy=fy=2yf_y = \frac{\partial f}{\partial y} = 2y

    2. 计算二阶导数:

      fxx=2fx2=2f_{xx} = \frac{\partial^2 f}{\partial x^2} = 2

      fyy=2fy2=2f_{yy} = \frac{\partial^2 f}{\partial y^2} = 2

      fxy=2fxy=0f_{xy} = \frac{\partial^2 f}{\partial x \partial y} = 0

      fyx=2fyx=0f_{yx} = \frac{\partial^2 f}{\partial y \partial x} = 0

    3. 构造 Hessian 矩阵:

      H=(fxxfxyfyxfyy)=(2002)H = \begin{pmatrix}f_{xx} & f_{xy} \\f_{yx} & f_{yy}\end{pmatrix} = \begin{pmatrix}2 & 0 \\0 & 2\end{pmatrix}

    4. 检查 Hessian 矩阵是否为正定矩阵。对于一个 2x2 的对称矩阵:

      H=(abbc)H = \begin{pmatrix}a & b \\b & c\end{pmatrix}

      要判断它是否正定,可以使用以下条件:

      • a>0a > 0
      • 矩阵的行列式 acb2>0ac - b^2 > 0

    对于我们的 Hessian 矩阵 HH

    a=2,b=0,c=2 a = 2, \quad b = 0, \quad c = 2

    显然:

    2>0 2 > 0

    行列式=2202=4>0 \text{行列式} = 2 \cdot 2 - 0^2 = 4 > 0

    因此,Hessian 矩阵 HH 是正定的,所以函数 f(x,y)=x2+y2f(x, y) = x^2 + y^2 是严格凸函数。

  • 海塞矩阵正定负定和极值的关系(ch1.3)(显然的)

    驻点的性质与海塞矩阵的行列式(determinant,det)和特征值(eigenvalues)的符号密切相关。以下是具体的关系:

    1. 正定矩阵:如果海塞矩阵 HH 是正定矩阵(即 det(H)>0\text{det}(H) > 0fxx>0f_{xx} > 0),则驻点是局部极小值点。这是因为此时所有的特征值都是正的,函数在该点附近是向上的碗形结构。

    2. 负定矩阵:如果海塞矩阵 HH 是负定矩阵(即 det(H)>0\text{det}(H) > 0fxx<0f_{xx} < 0),则驻点是局部极大值点。这是因为此时所有的特征值都是负的,函数在该点附近是向下的碗形结构。

    3. 不定矩阵:如果海塞矩阵 HH 的行列式小于零(即 det(H)<0\text{det}(H) < 0),则驻点是鞍点。这是因为此时特征值的符号不同,函数在该点附近的形状像马鞍。

    4. 退化矩阵:如果海塞矩阵 HH 的行列式等于零(即 det(H)=0\text{det}(H) = 0),则无法通过二阶导数判断驻点的性质,需要进一步分析。这是因为此时海塞矩阵是奇异的,不能提供足够的信息来确定驻点的性质。

    总结以上内容可以得出如下的关系:

    • det(H)>0\text{det}(H) > 0fxx>0f_{xx} > 0:局部极小值点。
    • det(H)>0\text{det}(H) > 0fxx<0f_{xx} < 0:局部极大值点。
    • det(H)<0\text{det}(H) < 0:鞍点。
    • det(H)=0\text{det}(H) = 0:需要进一步分析。
  • 求二元函数的极值(ch1.3)(先利用一阶必要条件求出所有驻点,然后利用海塞矩阵的正定性判定极值点。答案:一个极大一个极小,另两个什么都不是)

    问题:

    给定函数 f(x,y)=x33x+y33yf(x, y) = x^3 - 3x + y^3 - 3y,求其所有极值点。

    解题步骤:

    1. 计算一阶梯度以求驻点

      计算函数 f(x,y)f(x, y)xxyy 的一阶偏导数:

      fx=3x23\frac{\partial f}{\partial x} = 3x^2 - 3

      fy=3y23\frac{\partial f}{\partial y} = 3y^2 - 3

      解方程组 fx=0\frac{\partial f}{\partial x} = 0fy=0\frac{\partial f}{\partial y} = 0

      3x23=03x^2 - 3 = 0

      3y23=03y^2 - 3 = 0

      化简方程组得到:

      x2=1    x=±1x^2 = 1 \implies x = \pm 1

      y2=1    y=±1y^2 = 1 \implies y = \pm 1

      所以,驻点为 (1,1)(1, 1)(1,1)(1, -1)(1,1)(-1, 1)(1,1)(-1, -1)

    2. 计算二阶梯度以检验驻点的性质

      计算二阶偏导数:

      2fx2=6x\frac{\partial^2 f}{\partial x^2} = 6x

      2fy2=6y\frac{\partial^2 f}{\partial y^2} = 6y

      2fxy=0\frac{\partial^2 f}{\partial x \partial y} = 0

      对于每个驻点,计算 Hessian 矩阵:

      H=(6x006y)H = \begin{pmatrix}6x & 0 \\0 & 6y\end{pmatrix}

      驻点 (1, 1):

      H(1,1)=(6006)H(1,1) = \begin{pmatrix}6 & 0 \\0 & 6\end{pmatrix}

      行列式为:

      det(H(1,1))=6×60×0=36>0\text{det}(H(1,1)) = 6 \times 6 - 0 \times 0 = 36 > 0

      主对角线元素 6>06 > 0,所以 (1,1)(1, 1)局部极小值点

      驻点 (-1, -1):

      H(1,1)=(6006)H(-1,-1) = \begin{pmatrix}-6 & 0 \\0 & -6\end{pmatrix}

      行列式为:

      det(H(1,1))=6×60×0=36>0\text{det}(H(-1,-1)) = -6 \times -6 - 0 \times 0 = 36 > 0

      主对角线元素 6<0-6 < 0,所以 (1,1)(-1, -1)局部极大值点

      驻点 (1, -1):

      H(1,1)=(6006)H(1,-1) = \begin{pmatrix}6 & 0 \\0 & -6\end{pmatrix}

      行列式为:

      det(H(1,1))=6×60×0=36<0\text{det}(H(1,-1)) = 6 \times -6 - 0 \times 0 = -36 < 0

      由于行列式为负,(1,1)(1, -1)鞍点

      驻点 (-1, 1):

      H(1,1)=(6006)H(-1,1) = \begin{pmatrix}-6 & 0 \\0 & 6\end{pmatrix}

      行列式为:

      det(H(1,1))=6×60×0=36<0\text{det}(H(-1,1)) = -6 \times 6 - 0 \times 0 = -36 < 0

      由于行列式为负,(1,1)(-1, 1)鞍点

    结论:

    函数 f(x,y)=x33x+y33yf(x, y) = x^3 - 3x + y^3 - 3y 在点 (1,1)(1, 1) 处有一个局部极小值点,在点 (1,1)(-1, -1) 处有一个局部极大值点,而在点 (1,1)(1, -1)(1,1)(-1, 1) 处为鞍点。

  • 拉格朗日函数中等式乘子的性质(ch1.3)(等式约束的乘子 λi\lambda_i 是任意实数,不等式约束的乘子 μi0\mu_i\ge 0

二、填空 5 * 2'

  • 求解给定点的下降方向(ch1.3)

    求解一个函数的下降方向(Descent Direction),可以使用其梯度(Gradient)。梯度的反方向是函数值下降最快的方向。具体来说:

    1. 一元函数:

      • 对于一元函数 f(x)f(x),其梯度是导数 f(x)f'(x)
      • 下降方向是梯度的负方向,即 f(x)-f'(x)

      例子:
      f(x)=x2+2x+1f(x) = x^2 + 2x + 1

      • 首先计算导数 f(x)=2x+2f'(x) = 2x + 2
      • 在某点 x0x_0 处,下降方向是 f(x0)=(2x0+2)-f'(x_0) = -(2x_0 + 2)
    2. 二元函数:

      • 对于二元函数 f(x,y)f(x, y),其梯度是偏导数的向量 f(x,y)=(fx,fy)\nabla f(x, y) = \left( \frac{\partial f}{\partial x}, \frac{\partial f}{\partial y} \right)
      • 下降方向是梯度的负方向,即 f(x,y)=(fx,fy)-\nabla f(x, y) = \left( -\frac{\partial f}{\partial x}, -\frac{\partial f}{\partial y} \right)

      例子:
      f(x,y)=x2+y2+2x+2yf(x, y) = x^2 + y^2 + 2x + 2y

      • 首先计算偏导数:

        fx=2x+2\frac{\partial f}{\partial x} = 2x + 2

        fy=2y+2\frac{\partial f}{\partial y} = 2y + 2

      • 在某点 (x0,y0)(x_0, y_0) 处,梯度是 f(x0,y0)=(2x0+2,2y0+2)\nabla f(x_0, y_0) = (2x_0 + 2, 2y_0 + 2)
      • 下降方向是 f(x0,y0)=((2x0+2),(2y0+2))-\nabla f(x_0, y_0) = (-(2x_0 + 2), -(2y_0 + 2))
  • 线性规划的基本概念(ch2.1)

    这个不知道要怎么考,大致罗列一下所有的基本概念。

    • 定义:目标函数和约束条件都是线性的
    • 图解法:对于二元函数,可以使用平面图法进行求解。那么共有 4 种可能的结果,分别为:有唯一解、有无穷多解、有无界解、无可行解
    • 性质:分别从可行域和最优解两个角度展开:
      • 对于可行域,如果可行域是非空则可行域一定是凸集,这个很显然,用 ch1.2 的可行域凸集定理证明即可
      • 对于最优解,首先最优解如果存在则一定存在于所有约束条件中取等时,其次最优解如果存在一定是在可行域的顶点上。
  • 拟牛顿法的近似海塞矩阵公式(ch4.4)(见书 4.4.9和 4.4.10)

    对于原函数,有:

    Gk+1(xk+1xk)gk+1gkG_{k+1}(x_{k+1}-x_k) \approx g_{k+1} - g_k

    我们希望构造出的对称矩阵 Bk+1B_{k+1} 满足上式中 Gk+1G_{k+1} 的条件,于是就有下面两种拟牛顿条件,其中 Hk=Bk1H_k=B_k^{-1}

    Bk+1(xk+1xk)=gk+1gkHk+1(gk+1gk)=xk+1xk\begin{aligned}B_{k+1}(x_{k+1}-x_k)= g_{k+1} - g_k\\H_{k+1}(g_{k+1} - g_k) = x_{k+1}-x_k\end{aligned}

    我们记 sk=xk+1xk,yk=gk+1gks_k=x_{k+1}-x_k,y_k=g_{k+1} - g_k。则关于 Bk+1B_{k+1} 的 BFGS 校正迭代公式如下:

    {B0=2f(x0)Bk+1=Bk+ykykTskTykBkskskTBkskTBksk\begin{cases}B_0 = \nabla^2f(x_0)\\\displaystyle B_{k+1} = B_k + \frac{y_ky_k^T}{s_k^Ty_k} - \frac{B_ks_ks_k^TB_k}{s_k^TB_ks_k}\end{cases}

三、证明 1 * 10'

  • 证明高维凸规划问题(ch1.2)

    关键在于证明可行域是凸集,目标函数是凸函数。证明可行域是凸集比较简单,书 ch1.2 中的「定理1.2.18」给出了详细的可行域凸性判定定理。证明目标函数是凸函数有三种方法,书 ch1.2 中「定理1.2.19-1.2.21」分别从函数值、一阶导数、二阶导数三个角度进行了凸函数判定定理的介绍,下面仅从二阶导数的角度给出凸函数判定的示例。

    二元凸函数

    选取函数 f(x,y)=3x2+2xy+4y2f(x, y) = 3x^2 + 2xy + 4y^2,计算 Hessian 矩阵:

    H(f)=(2fx22fxy2fyx2fy2)=(6228)H(f) = \begin{pmatrix}\frac{\partial^2 f}{\partial x^2} & \frac{\partial^2 f}{\partial x \partial y} \\\frac{\partial^2 f}{\partial y \partial x} & \frac{\partial^2 f}{\partial y^2}\end{pmatrix} = \begin{pmatrix}6 & 2 \\2 & 8\end{pmatrix}

    Hessian 矩阵的特征值均为正数,因此海塞矩阵是正定的,因此 f(x,y)=3x2+2xy+4y2f(x, y) = 3x^2 + 2xy + 4y^2 是凸函数并且是严格凸的。

    三元凸函数

    选取函数 g(x,y,z)=2x2+2xy+3y2+2xz+z2g(x, y, z) = 2x^2 + 2xy + 3y^2 + 2xz + z^2,计算 Hessian 矩阵:

    H(g)=(2gx22gxy2gxz2gyx2gy22gyz2gzx2gzy2gz2)=(422260202)H(g) = \begin{pmatrix}\frac{\partial^2 g}{\partial x^2} & \frac{\partial^2 g}{\partial x \partial y} & \frac{\partial^2 g}{\partial x \partial z} \\\frac{\partial^2 g}{\partial y \partial x} & \frac{\partial^2 g}{\partial y^2} & \frac{\partial^2 g}{\partial y \partial z} \\\frac{\partial^2 g}{\partial z \partial x} & \frac{\partial^2 g}{\partial z \partial y} & \frac{\partial^2 g}{\partial z^2}\end{pmatrix} = \begin{pmatrix}4 & 2 & 2 \\2 & 6 & 0 \\2 & 0 & 2\end{pmatrix}

    Hessian 矩阵的特征值均为正数,因此海塞矩阵是正定的,因此 g(x,y,z)=2x2+2xy+3y2+2xz+z2g(x, y, z) = 2x^2 + 2xy + 3y^2 + 2xz + z^2 是凸函数并且是严格凸的。

    当然我们也可以通过计算主子矩阵的行列式来代替计算矩阵的特征值,上述二元同样也可以。例如对于下述对称矩阵:

    A=(210121012)A = \begin{pmatrix} 2 & -1 & 0 \\ -1 & 2 & -1 \\ 0 & -1 & 2 \end{pmatrix}

    我们计算其所有主子矩阵的行列式:

    • 一阶主子矩阵 (2)\begin{pmatrix} 2 \end{pmatrix} 的行列式为 22
    • 二阶主子矩阵 (2112)\begin{pmatrix} 2 & -1 \\ -1 & 2 \end{pmatrix} 的行列式为:

      det(2112)=22(1)(1)=41=3\det \begin{pmatrix} 2 & -1 \\ -1 & 2 \end{pmatrix} = 2 \cdot 2 - (-1) \cdot (-1) = 4 - 1 = 3

    • 三阶主子矩阵即矩阵 AA 本身的行列式:

      det(A)=210121012=4\det(A) = \begin{vmatrix} 2 & -1 & 0 \\ -1 & 2 & -1 \\ 0 & -1 & 2 \end{vmatrix}=4

    由于所有主子矩阵的行列式都大于零,矩阵 AA 是正定的。假如这是一个函数的海塞矩阵,则该函数是凸函数并且是严格凸的。

四、计算 68'

  • 计算点到超平面的距离,转化为等式约束的最优化问题(ch1.3)

    这个应该是很显然的一道题,我们定义目标函数为点 aa 到超平面上点 xx 的距离,约束条件为 xx 在超平面上。求解方法就是构造一个拉格朗日函数,然后利用一阶必要条件求解即可。

  • 线性规划问题中求对偶问题表达式(ch2.3.1)

    直接看 2.3.1 的实战演练即可。

  • 线性规划问题中已知原问题最优解,求解对偶问题最优解(ch2.3.2)(利用互补松弛定理、原问题和对偶问题最优解相等)

    见 ch2.3.2 有详细求解步骤。注意可能试卷中给的是原问题的最优解,需要求解对偶问题的最优解,原理是一样的,把原问题看成对偶问题,对偶问题看成原问题,就和 ch2.3.2 的求解逻辑完全一致了。

  • 0.618 法求精确步长(ch3.2)

    每次缩函数值大的点即可。

  • 最速下降法、牛顿法求解方法及其优缺点,共轭梯度法的优缺点(ch4)(直接给步长)

    同样很显然的一道题,透露说只要迭代一步?总之就那两个迭代公式,况且步长都给了,记一下方向的迭代即可,最速下降法就是负梯度方向,牛顿法就是二阶梯度的逆乘负梯度作为新的方向。至于优缺点,简单记忆一下即可。最速下降法无非程序简单但因为搜索方向是正交的导致收敛速度差,牛顿法虽然收敛速度快了但是存储的内容太多导致计算量变大,开销增加。

    至于共轭梯度法的优缺点,优点就是该法是最速下降法和牛顿法的结合,每次的搜索方向是共轭的,这样就不用存储海塞矩阵并且收敛速度往往比最速下降法更快。主要用于解决正定二次函数的极小化问题。但在解决其余问题时可能会对搜索步长有极高的依赖,一旦搜索步长不够精准会导致整体的精度下降,收敛速度也会下降。

  • 有效集方法解不等式约束的二次规划问题(ch6)(小概率考到,考到就gg,因为不会)

  • 等式约束下的二次罚函数法(ch7)(一个约束函数,一个等式约束条件,共 4 问)

    1. 用拉格朗日乘子法求出最优解 xx^* 和拉格朗日乘子 λ\lambda^*

    2. 写出二次罚函数表达式 PE(x,σ)P_E(x,\sigma),讨论罚因子 σ\sigma 在什么取值范围下可以使海塞矩阵 2PE(x,σ)\nabla^2P_E(x,\sigma) 正定

    3. 求最优解 xx^*(直接计算一阶梯度 \nabla 并用 σ\sigma 表示 xx^* 即可)

    4. σ\sigma \to \infty​ 时求最优解,并判断是否和第一问计算结果一致

    示例:

    等式约束的二次罚函数法

  • 约束最优化问题,约束条件中只有不等式(ch7)(用拉格朗日乘子法,已知有 3 个不等式,且 232^3 个答案中只有一个是合法结果)

    拉格朗日乘子法解一般约束优化问题

很遗憾将这门课学成了面向已知考试题型的过拟合形式。我并不觉得我掌握了多少优化理论的知识,最多称得上知道了优化问题的大致分类和一些基本的优化应用。从我的笔记就可以看出,自第三章开始就没怎么涉及到理论的证明,确实是证不明白🤡。但这门课自下届开始就被取消了hh。。。。你我皆是局内人,祝好。

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

前言

学科地位:

主讲教师学分配额学科类别
杨琬琪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':没考过,大约是简答题加强版。注意逻辑一定要清晰。
]]>
+ 计算机系统基础

前言

学科地位:

主讲教师学分配额学科类别
闫文珠3.5专业课

成绩组成:

实验(9次)平时作业期末(闭卷)
20%30%50%

教材情况:

课程名称选用教材版次作者出版社ISBN号
计算机系统基础《计算机系统基础》4袁春风机械工业出版社978-7-111-60489-1

学习资源:

期末考试:

  • 简答(类似于作业)
  • 计算(类似于作业)
  • 综合(尽可能收集老师们的复习资料)

第1章 计算机系统概述

1.1 计算机基本工作原理

冯.诺依曼结构 - 计算机模型

如图,冯诺依曼认为计算机应该由上述五个部分组成,相互协作完成任务:

  1. 存储器:存储数据和指令
  2. 运算器:可以进行四则运算和逻辑运算
  3. 控制器:自动取指令来执行
  4. 输入设备:用户输入
  5. 输出设备:系统输出

现代计算机结构模型

现代计算机结构模型

1.2 程序的开发与运行

pass

1.3 计算机系统的层次结构

计算机系统的层次结构

1.4 计算机系统性能评价

一个完整的计算机系统由软件和硬件共同组成,而硬件的性能对其起决定性作用,但是硬件的性能检测和评价比较困难。本目介绍综合性测试、评价硬件性能的方法。

1.4.1 计算机性能的定义

考量一个计算机性能的基本指标有两个,分别为吞吐率(throughput)和响应时间(response time),下面做出相应的理论解释和自己的理解。

理论定义

  • 吞吐率:计算机系统单位时间内完成的工作量。

  • 响应时间:计算机系统完成一个作业从提交开始到作业完成所用的时间。

个人理解:我们以备份文件为例。对于一个大文件,我们希望计算机系统的吞吐率性能足够好,这样处理单文件的能力就会很高。对于很多的小文件,吞吐率高没有什么决定性作用,起决定性作用的是响应时间,我们希望响应时间尽可能的短,这样处理大规模小文件集合的时候就会有优势。那么相应的适用场景也就合理了。

适用场景:对于多媒体应用场景,就相当于大型文件,此时自然希望吞吐率尽可能的高。对于银行、证券交易业务,就相当于大规模小文件集合,我们希望系统的响应时间尽可能的短,这样就可以在单位时间解决更多的小型业务。当然,两者性能均优异自然是最佳选择,对于一些用户体验很重要的应用,比如 ATM、文件服务、Web 服务 等等,就需要两者性能均优。

1.4.2 计算机性能的测试

如果不考虑应用背景而直接比较计算机性能,往往通过程序的执行时间来衡量。下附程序的执行时间组成图:

程序的执行时间(用户感受到的)

我们关注的是用户 CPU 时间。那么如何计算呢?我们引入三个相关计算量:

  1. 时钟周期:即一个时钟脉冲信号持续的时间。由于计算机执行一条指令得过程会被分解为不同的小模块进行,因此我们需要控制每一个小模块的执行过程。引入脉冲信号来控制信号的发出和作用的时间等等。
  2. 时钟频率:单位时间内的时钟周期数。即上述时钟周期的倒数。
  3. CPI(Cycles Per Instruction)
    • 对于一条指令而言:CPI 指的是一条指令所需的时钟周期数
    • 对于一个程序或一台机器而言:CPI 指的是该程序或机器的指令集中所有指令平均执行所需的时钟周期数

计算用户 CPU 时间的公式:

用户 CPU 时间=程序总时钟周期数×时钟周期=程序总时钟周期数÷时钟频率\begin{equation*}\begin{aligned}\text{用户 CPU 时间} &= \text{程序总时钟周期数} \times \text{时钟周期} \\&= \text{程序总时钟周期数} \div \text{时钟频率}\end{aligned}\end{equation*}

补充说明:

  • 显然,用户 CPU 时间与计算机性能成反比
  • 上述三个相关计算量是相互制约的,不存在只提升或下降某一个指标

1.4.3 用指令执行速度进行性能评估

pass

1.4.4 用基准程序进行性能评估

pass

1.4.5 Amdahl 定律

对于系统中某个部分(软件或硬件)进行更新所带来的系统的性能提升,是取决于这部分原本的运行时间占总运行时间,以及这部分升级了多少倍两者共同决定的,用公式表示改进后系统的执行时间

改进后的执行时间=改进部分原本占用的时间改进的倍数+剩余部分占用的时间\text{改进后的执行时间} = \frac{\text{改进部分原本占用的时间}}{\text{改进的倍数}}+\text{剩余部分占用的时间}

当然整体改进倍数也就可以表示为:

整体改进倍数=1改进部分原本占用的时间比例改进的倍数+剩余部分占用的时间比例\text{整体改进倍数} = \frac{1}{ \frac{\text{改进部分原本占用的时间}\textbf{比例}}{\text{改进的倍数}} + \text{剩余部分占用的时间}\textbf{比例} }

1.5 本书的主要内容与组织结构

第一章主要介绍:计算机的基本工作原理、计算机系统的基本组成、程序的开发与执行过程、计算机系统的层次结构以及性能评价的基本概念。

第二章主要介绍:各类数据在计算机中的表示与运算。

第三章主要介绍:高级语言中的过程调用和控制语句所对应的汇编指令序列,以及各类数据结构元素的访问所对应的汇编指令。

第四章主页介绍:如何将多个模块链接起来生成一个可执行的目标文件。

第2章 数据的机器级表示与处理

本章主要从四个方面展开,分别为:数值数据的表示、非数值数据的表示、数据的存储、数据的运算。

2.1 数制和编码

2.1.1 信息的二进制编码

  • 计算机内部所有的信息都采用二进制 01 进行编码

  • 需要编码的机器级数据分为以下两类:

    1. 数值数据:整数(带符号、无符号)、浮点数
    2. 非数值数据:逻辑数、西文字符、汉字字符
  • 理解真值和机器数的概念

2.1.2 进位计数制

进制表示

  1. 二进制:后缀为 B(即 Binary,如 0110B)
  2. 八进制:后缀为 O(即 Octal,如 12673O)
  3. 十进制:后缀为 D(即 Decimal,如 291D;可省略,即 291)
  4. 十六进制:后缀为 H(即 Hexadecimal,如 3F9H;可以前缀 0x 标记,即 0x3F9)

进制转换:一般先将数据转换为二进制,再进行相应转换

  1. R 进制转换为 十 进制
  2. 十进制转换为 R 进制
  3. 二、八、十六进制的相互转换

2.1.3 定点与浮点的表示

定点数:小数点位置约定在固定位置的数

  • 定点小数:小数点总是固定在数的最左端,可以用来表示浮点数的尾数部分
  • 定点整数:小数点总是固定在数的最右端,可以用来表示整数

浮点数:小数点位置约定为可浮动的数。对于任意一个实数 X 都可以表示成下面的形式:

X=(1)S×M×REX = (-1)^{S} \times M \times R^E

  • SS0011 决定当前实数的正负
  • MM 是一个二进制定点小数,称为实数的尾数。有效位数越多,说明精度越高
  • EE 是一个二进制定点整数,称为实数的阶数。决定小数点的位置
  • RR 称为实数的基数。由数制决定

取值范围:假定浮点数的尾数为纯小数即尾数不为零,则浮点数 XX 的绝对值的最小值表示为 0.00...1×R11...10.00...1 \times R^{-11...1},最大值表示为 0.11...1×R11...10.11...1 \times R^{11...1}。假设阶的位数为 mm,尾数的位数为 nn。则浮点数 XX 的绝对值真值的取值范围为:

Rn×R(Rm1)X(1Rn)×RRm1R^{-n} \times R^{-(R^m-1)} \le |X| \le (1-R^{-n}) \times R^{R^m-1}

2.1.4 定点数的编码表示

  1. 原码表示法
  2. 补码表示法
    • 模运算:对一个负数取模=负数\text{对一个负数取模} = \text{模} - |\text{负数}|
    • 补码的定义:[X]=2n+X[X]_{\text{补}}=2^n+X,对于 nn 位的运算系统,可以表示补码的数据范围为 2n1X<2n1-2^{n-1} \le X < 2^{n-1}
  3. 反码表示法
  4. 移码表示法

2.2 整数的表示

2.2.1 无符号整数和带符号整数的表示

无符号整数:所有的二进制位都用来表示数值。比如对于 nn 位的二进制数,无符号整数的数值范围为 [0,2n1][0,2^{n} - 1]

有符号整数:第一位二进制位必须用来进行正负性表示,后 n1n-1 位用来表示数值。同样对于 nn 位的二进制数,有符号整数的数值范围为 [2n1,2n1)[-2^{n-1}, 2^{n-1})

2.2.2 C 语言中的整数及其相互转换

C 语言标准中规定:若运算中同时有无符号和带符号整数,则按无符号整数运算

搞懂这张真值比较表即可:

真值比较表

2.3 浮点数的表示

2.3.1 浮点数的表示范围

我们以 32 位浮点数为例。32 位中:

  • 第 0 位为符号位 (Sign)\text{(Sign)}。1 表示负数,0 表示正数
  • 第 1 - 8 位为阶数位 (Exponent)\text{(Exponent)}。采用移码表示,通常需要加上偏执常数 128
  • 第 9 - 31 位为尾数位 (Fraction)\text{(Fraction)}。规格化尾数的表示形式为 0.1bb...b0.1\text{bb...b}

正数的最大值:0.11...1×211...1=(1224)×21270.11...1 \times 2^{11...1}=(1-2^{-24}) \times 2^{127}

正数的最小值:0.10...0×200...0=21×2128=21290.10...0 \times 2^{00...0} = 2^{-1} \times 2^{-128}=2^{-129}

浮点数的表示范围

2.3.2 浮点数的规格化

分为左规和右规,目的是尽可能多的得到有效位,同时使得浮点数的表示具有唯一性

2.3.3 IEEE 754 浮点数标准

与前面提到的规则略有不同。对于下列两种精度的浮点数:有以下规定:

  1. 对于尾数:隐藏位 1 还是存在,不过现在不是在小数点右边,而是在小数点左边
  2. 对于阶数:同样采用移码的形式,只不过现在的偏执常数不是 2n12^{n-1},而是 2n112^{n-1}-1。因此单精度和双精度浮点数的偏执常数分别为 2811=127,21111=10232^{8-1}-1=127,2^{11-1}-1=1023

IEEE 754 浮点数标准

对于本目,需要掌握 IEEE 754 规格化小数真值之间的转换,转换规则如下:

十进制小数转化为IEEE754的小数

IEEE754小数转化为十进制小数

浮点数的 5 种表示形式:

浮点数的5种表示形式

单精度浮点数的各种极端情况:

单精度浮点数的各种极端情况

2.3.4 C 语言中的浮点数类型

主要掌握强制类型转换。

  • int 到 float 可能丢失有效数字。因为 int 的有效数位比 float 多
  • int、float 到 double 数值不变
  • double、float 到 int 可能丢失有效数字或溢出
  • double 到 float 可能丢失有效数字或溢出

2.4 十进制数的表示

2.4.1 用 ASCII 码字符表示

pass

2.4.2 用 BCD 码表示

pass

2.5 非数值数据的编码表示

2.5.1 逻辑值

1 个 二进制数表示 1 个逻辑值。

2.5.2 西文字符

西文字符集的编码是 ACSII 码,一般是 7 位二进制位 b6b5b4b3b2b1b0b_6b_5b_4b_3b_2b_1b_0 来表示。可能会有第八位 b7b_7,该位往往用来进行奇偶校验。

2.5.3 汉字字符

汉字数量庞大,且是表意形的字符,为了在计算机中表示汉字,我们需要处理三个问题,分别是输入表示、机器表示、输出表示:

  1. 汉字的输入码:用于对输入的中文字符进行编码

  2. 字符集与汉字内码:用于系统内部的存储、查找、传送等处理

  3. 汉字的字模点阵码和轮廓描述:用于显示和打印

概念定义:

  • 图像的基本单位是像素,是真实世界的数字表示

  • 图形的往往是数学或程序算法生成的,是虚构的

2.6 数据的宽度和存储

2.6.1 数据的宽度和单位

计算机中数据的表示宽度及其单位

  • 二进制信息的最小单位:比特(bit)

  • 最小寻址单位:字节(Byte),其中 1Byte = 8 bit

  • 更大的单位表示:字(word),其中 1 word = 2 bit | 4 bit | 8 bit | 16 bit

计算机中数据的运算、存储、传输的部件宽度及其单位

  • 数据通路宽度:字长

2.6.2 数据的存储和排列顺序

对于一个 32 bit 的数 1000 0000 0000 0000 0000 0000 0000 0110 而言,我们定义:

  • 最高有效位(MSB,Most Significant Bit):上述 32 位数的最左边的一位 1

  • 最低有效位(LSB,Least Significant Bit):上述 32 位数的最右边的一位 0

  • 最高有效字节(MSB,Most Significant Byte):上述 32 位数的最左边的一字节 1000 0000

  • 最低有效字节(LSB,Least Significant Byte):上述 32 位数的最右边的一字节 0000 0110

现代计算机都是对字节进行编址方式。由于程序中对每个数据只给定一个地址,那么对于多字节的数据如何根据仅有的一个地址进行 CPU 的内存分配呢?这就是经典的字节排序问题。现在介绍两种内存分配方式:大端方式、小端方式

  • 大端方式:最低有效字节存放在最高位。对应的机器码中最低有效字节在最右端,例如数值 0x0000001E 在大端方式下存储为 00 00 00 1E

  • 小端方式:最低有效字节存放在最低位。对应的机器码中最低有效字节在最左端,例如数值 0x0000001E 在小端方式下存储为 1E 00 00 00

  • 举例说明:对于机器数 0xFFFFFFF6 而言,下面的表示方式中,左侧为小端存储,右侧为大端存储

    左侧为小端存储,右侧为大端存储

2.7 数据的基本运算

学完了基本的数据表示以后,让我们开始学习数据的基本运算。高级语言中涉及到的各种运算,都会被编译成底层的算术运算指令和逻辑运算指令实现,本目要介绍的也就是其中的运算逻辑。

2.7.1 按位运算和逻辑运算

按位运算

|&~^
按位或按位与按位取反按位异或

逻辑运算

||&&!
逻辑或逻辑与逻辑非

2.7.2 左移运算和右移运算

无符号整数采用逻辑移位

  • 左移:高位移出,低位补 0。如果移出为 1,则溢出
  • 右移:高位补 0,低位移出

有符号整数采用算数移位

  • 左移:高位移出,低位补 0。如果左移前后符号位不同,则溢出
  • 右移:高位补符号,低位移出

2.7.3 位扩展运算和位截断运算

扩展操作:在短数向长数转换时

  • 有符号整数采用符号扩展:前方补符号位
  • 无符号整数采用 0 扩展:前方补 0
1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdio.h>

int main() {
short int si = -32768;
unsigned short usi = si;
int i = si;
unsigned ui = usi;

printf("si = \t%hd\t%x\n", si, si);
printf("usi = \t%hu\t%x\n", usi, usi);
printf("i = \t%d\t%x\n", i, i);
printf("ui = \t%u\t%x\n", ui, ui);
}

上述代码在 32 位大端机器上的输出:

1
2
3
4
si  =   -32768  8000
usi = 32768 8000
i = -32768 ffff8000
ui = 32768 8000

分析:

  • siusi 是同一个机器数的不同真值结果,由于数位没有长短的变化,故十六进制表示结果相同
  • i 是有符号整数的位扩展,采用符号扩展,补充符号位 1,故向前补充了 16 个 1
  • ui 是无符号数的位扩展,采用 0 扩展,补充 0,故向前补充了 16 个 0

截断操作:在长数向短数转换时

  • 有符号整数,高位直接丢弃
  • 无符号整数,高位直接丢弃
1
2
3
4
5
6
7
8
9
10
11
#include <stdio.h>

int main() {
int i = 32768;
short si = (short)i;
int j = si;

printf("i = \t%d\t%x\n", i, i);
printf("si = \t%hd\t%x\n", si, si);
printf("j = \t%d\t%x\n", j, j);
}

上述代码在 32 位大端机器上的输出:

1
2
3
i  =    32768   00008000
si = -32768 8000
j = -32768 ffff8000

分析:

  • i 的真值和机器数很显然
  • si 的机器数就是将 i 的机器数截取了后 16 位的结果,在识别为有符号数以后,得到的真值就是最小的负数 -32768
  • j 是有符号整数的位扩展运算,补充符号位 1
  • 可以发现了截断错误,而这种错误编译器一般是不会发现的!

2.7.4 整数加减运算

整数加/减运算部件

部件解读:对于无符号整数和有符号整数而言,都可采用该运算部件进行运算

  • 输入:

    1. 运算数 A:整数 A 的补码 01 序列
    2. 运算数 B:整数 B 的补码 01 序列
    3. 判别数 Sub:A+BSub=0A-BSub=1
  • 输出:

    1. 结果 Sum:表示整数 A+B 的计算结果

    2. 零标志 ZF:表示 A+B 是否为 0

    3. 符号标志 SF:表示计算结果 Sum 的有效范围内的最高位二进制数值

    4. 溢出标志 OF:判断有符号整数的相加结果是否溢出。计算方式:

      • 若 A 与 B’ 首位同号且与 Sum 的首位不相同,则计算溢出,OF = 1
      • 反之则计算未溢出,OF = 0
    5. 进/借位标志 CF:判断无符号整数的相加结果是否溢出。计算方式:

      • 对于加法:此时的判别数 Sub = 0

        • 若有进位,即 Cout = 1,则对于当前加法器表示计算溢出,CF = 1
        • 若没有进位,即 Cout = 0,则没有计算溢出,CF = 0
      • 对于减法:此时的判别数 Sub = 1

        • 若有借位,则 Cout = 0,则对于当前加法器表示计算结果是负数,即计算溢出,CF = 1
        • 若没有借位,则 Cout = 1,则对于当前加法器表示计算结果是正数,即计算未溢出,CF = 0
      • 综上所述:

        CF=SubCout\text{CF} = \text{Sub} \oplus \text{Cout}

对于两个机器数 x = 1000 0110y = 1111 0110

加法:计算 x + y

MUX:{y=ySub=0Adder:{x:1000 0110y=y:1111 0110Sub:0Result:1 0111 1100Signals:{ZF=0SF=0OF=1CF=1Real Value:{signed:124(134+24628)unsigned:124(134+24628)\begin{aligned}&\text{MUX}:\begin{cases}y' &= y \\\text{Sub} &= 0\end{cases}\\&\text{Adder}:\begin{cases}x: & 1000\ 0110 \\y'=y: & 1111\ 0110 \\\text{Sub}: & 0\end{cases}\\&\text{Result}: 1 \ 0111\ 1100\\&\text{Signals}:\begin{cases}\text{ZF} = 0 \\\text{SF} = 0 \\\text{OF} = 1 \\\text{CF} = 1\end{cases}\\&\text{Real Value}:\begin{cases}\text{signed}: &124(134+246-2^8)\\\text{unsigned}: &124(134+246-2^8)\end{cases}\end{aligned}

减法:计算 x - y

MUX:{y=ySub=1Adder:{x:1000 0110y=y:0000 1001Sub:1Result:0 1001 0000Signals:{ZF=0SF=0OF=0CF=1Real Value:{signed:112unsigned:144(134246+28)\begin{aligned}&\text{MUX}:\begin{cases}y' &= \overline{y} \\\text{Sub} &= 1\end{cases}\\&\text{Adder}:\begin{cases}x: & 1000\ 0110 \\y'=\overline{y}: & 0000\ 1001 \\\text{Sub}: & 1\end{cases}\\&\text{Result}: 0 \ 1001\ 0000\\&\text{Signals}:\begin{cases}\text{ZF} = 0 \\\text{SF} = 0 \\\text{OF} = 0 \\\text{CF} = 1\end{cases}\\&\text{Real Value}:\begin{cases}\text{signed}: &-112 \\\text{unsigned}: &144(134-246+2^8)\end{cases}\end{aligned}

对于 n=8 的情况而言,数据范围是:

{signed:[2n1,2n1)=[128,127]unsigned:[0,2n)=[0,255]\begin{cases}\text{signed}: &[-2^{n-1},2^{n-1}) &=& [-128,127] \\\text{unsigned}: &[0,2^n) &=& [0,255]\end{cases}

2.7.5 整数乘除运算

整数乘法中。两个 n 位的整数相乘得到一个 2n 位整数,但是一般只保留低 n 位,高 n 位舍弃。判断乘法溢出规则如下:

  • 对于无符号整数乘法:若高 n 位全 0 则没溢出,反之溢出
  • 对于有符号整数乘法:若高 n 位全等于 低 n 位的首尾,则没溢出,反之溢出

整数除法中。两个 n 位整数相除,溢出异常和小数舍去规则如下:

  • 除了最小负数除以 -1 会发生溢出,以及除 0 会发生异常以外,其余情况全部是正常运算
  • 无论是带符号还是无符号,所有产生的小数部分全部舍弃,效果就是向 0 取整

2.7.6 常量的乘除运算

常量乘法运算中。由于直接当做整数进行乘法会导致更高的消耗,因此编译器一般会将其拆分为移位运算与加减运算的综合运算

  • 比如表达式 x * 20 中的 20 会被二进制拼凑分解为 2^4 + 2^2,也就是将 x 左移 4 位的结果与 x 左移 2 位的结果相加

常量除法运算中。同样由于时间开销极大,也是采用移位进行优化,但是与加法略有不同,对于除数是一个 2k2^k 整数的形式时:

  1. 若被除数是无符号数或者有符号正数,直接低位丢弃,高位按照相应的规则补齐
  2. 若被除数是有符号负数,且移出低位时有 1,则需要先将被除数加上 2k12^k-1,再执行第一步的操作

2.7.7 浮点数运算

以浮点数加减为例。

  • 转换:转换为二进制数
  • 对阶:小阶往大阶靠拢
  • 尾数加减
  • 尾数规格化:只能让 1 出现在小数点的左边一位
  • 阶码溢出判断

第3章 程序的转换及机器级表示

上一章节我们学习了数据的机器级表示,本章我们将学习程序的机器级表示。包括我们写的屎山是怎么转化成机器能理解的 01 序列的(程序的转换)以及机器理解东西的规则是什么(机器级表示,以 IA-32 指令集为例)。

程序的转换 大约用下面这个流程图就能概括了:

源代码 预处理 源代码 编译 汇编代码 反汇编汇编 机器代码 链接 机器代码\begin{aligned}\text{源代码 $\xrightarrow[]{\text{预处理}}$ 源代码 $\xrightarrow{\text{编译}}$ 汇编代码 $\xrightleftharpoons[\text{反汇编}]{\text{汇编}}$ 机器代码 $\xrightarrow[]{\text{链接}}$ 机器代码}\end{aligned}

机器级表示 无法直接阅读,只能从更高层面的汇编来理解。从上图可以看出,汇编代码可以从「源代码编译」而来,也可以从「机器代码反汇编」而来。本章主要就是按 AT&T\text{AT\&T} 格式对上述两种来源的汇编代码进行分析和理解。

本章内容梗概:

  • 3.1 节:概述高级语言到汇编语言到机器语言的转换;
  • 3.2 节:概述 IA-32 指令集的三个主要方面,分别为 数据类型及其存储格式寄存器存取数据的逻辑机器指令的汇编格式,为后续小节的详细展开做铺垫;
  • 3.3 节:详细展开 机器指令的汇编格式
  • 3.4、3.5 节:以 C 语言为高级语言示例,详细展开 数据类型及其存储格式机器指令的汇编格式
  • 3.6 节:分析异常情况的汇编表示和内部逻辑。

其实很多时候并非写代码才会让电脑起反应,然后根据翻译得到的机器指令做事,我们随便碰些什么都对应很多的指令。下图是我对 读、写、存文件 的可视化展示,以此来加强对计算机内部运作机理的宏观理解:

wdf-is-sys

补充其中的几个概念:

  • 硬盘 (SSD/HDD):持久存储设备
  • 内存 (RAM):临时存储设备
  • 中央处理器 (CPU):中央处理单元

下面经常出现的 寄存器 (Register) 就存在于 CPU 中,作为存储数据的媒介而存在,存取效率极高,也因此决定了 CPU 的处理效率和整体系统的性能。

本章我们要学的「指令集」其实是 CPU 运作机理的一个重要部分。它定义了 CPU 理解和执行指令的规则和方法,也是程序员和编译器与 CPU 交互的接口。

3.1 程序转换概述

3.1.1 机器指令及汇编指令

计算机只认识机器指令,但是机器指令只是一个 01 位串,可读性很差,因此引入汇编指令用于助记。由机器指令组成的程序叫机器语言程序,由汇编指令组成的程序叫汇编语言程序。机器语言程序和汇编语言程序都是机器级程序。将汇编语言转化为机器语言的程序叫汇编程序,将机器语言转化为汇编语言的程序叫反汇编程序

但是汇编语言程序还是很抽象,因此引入高级语言程序。现在我们要解决的是如何将高级语言程序转化为机器语言程序。至于如何转化能够使得时间、空间都最优,这是编译优化要解决的事,本课程不予讨论。

3.1.2 指令集体系结构

回看 1.3 节可以发现,指令集体系结构 (Instruction Set Architecture,简称 ISA)\text{(Instruction Set Architecture,简称 ISA)} 是连接计算机硬件和软件之间的桥梁,将物理上的计算机硬件抽象成了逻辑上的虚拟计算机,称为机器语言级虚拟机。

ISA 定义了机器语言级虚拟机的所有功能,是一套 与 CPU 交互 的规则。

3.1.3 生成机器代码的过程

本目介绍高级语言转化为机器语言的过程,以 C 语言该高级语言为例。假设当前有一个 C 语言文件 main.c,利用 gcc 工具可以实现。

  1. 预处理:在源程序中插入所有用 #include 命令指定的文件和用 #define 声明指定的宏。生成的 main.i文本文件

    1
    gcc -E main.c -o main.i
  2. 编译:将预处理后的源程序编译成汇编语言程序。生成的 main.s文本文件

    1
    gcc -S main.i -o main.s
  3. 汇编:通过汇编程序将编译出来的汇编语言程序转化为可重定位的机器语言程序。生成的 main.o二进制文件

    1
    gcc -c main.s -o main.o
  4. 链接:将多个可重定位的机器语言程序以及库程序链接为可执行文件

    1
    gcc main.o -o main

上述第三步汇编出来的二进制文件 main.o 是不可读的,但是我们可以通过将其反汇编成文本文件,进而可读。可以采用 objdump 工具,通过 -d 参数命令将其反汇编为由汇编语言构成的文本文件,命令如下:

1
objdump -d main

需要补充的一点是:本书中的汇编语言程序的格式采用 AT&T\text{AT\&T},这也是 gcc 和 objdump 使用的默认汇编格式。

3.2 IA-32 指令系统概述

3.2.1 数据类型及其格式

IA-32 支持的数据类型及格式与 C 语言之间的对应关系:

C语言声明Intel操作数类型汇编指令长度后缀存储长度(位)
(unsigned) char整数/字节bb8
(unsigned) short整数/字ww16
(unsigned) int整数/双字ll32
(unsigned) long int整数/双字ll32
(unsigned) long long int--2*32
char *整数/双字ll32
float单精度浮点数ss32
double双精度浮点数ll64
long double扩展精度浮点数tt80/96

3.2.2 寄存器组织和寻址方式

寄存器组织:共有三大类

寄存器组织

  • 8 个通用寄存器,其中

    • EAX, EBX, ECX, EDX 均为 32 位寄存器
    • AX, BX, CX, DX 均为 16 位寄存器
    • AH, BH, CH, DH 均为高 8 位寄存器
    • AL, BL, CL, DL 均为低 8 位寄存器
  • 2 个专用寄存器

  • 6 个段寄存器

寻址方式:

  1. 立即寻址:指令直接给出操作数。准确的说不用寻址了;
  2. 寄存器寻址:指定寄存器 R 的内容为操作数。在寄存器中找数据;
  3. 存储器寻址:基址+变址×比例因子+偏移地址。在内存或者硬盘中找数据。

存储器寻址示例 - AT&T格式汇编

3.2.3 机器指令格式

正如上面所言,机器指令本质上就是一个人为定义的含有某些意义的 01 序列,查询相应的表可以得知对应的汇编语言格式。不需要记忆多少东西,遇到不懂的直接查表即可。

举个例子。下方表格展示了一个经典的转换,怎么转换不重要,重要的是能理解对应汇编语言的逻辑:

机器语言(16 进制格式)汇编语言(AT&T 格式)
C744 2404 0100 0000movl $0x1, 0x4 (%esp)

3.3 IA-32 常用指令类型及其操作

3.3.1 传送指令

符号辨析:R\text{R}Register\text{Register} 表示取寄存器操作数,M\text{M}Memory\text{Memory} 表示取存储器操作数,mov\text{mov}Move\text{Move} 用于数据传送,lea\text{lea}Load Effective Address\text{Load Effective Address} 用于地址计算。

数据传送语句:mov 0x8(%ebp,%edx,4), %eax 实现的功能为 R[eax] = M[R[ebp] + R[edx] * 4 + 8]。表示将计算出来的地址 R[ebp] + R[edx] * 4 + 8 对应的在存储器中的值 M[R[ebp] + R[edx] * 4 + 8] 赋给寄存器 %eax,此时寄存器中存储的信息为操作数的 R[eax]

地址计算语句:lea 0x8(%ebp,%edx,4), %eax 实现的功能为 R[eax] = R[ebp] + R[edx] * 4 + 8。表示将计算出来的地址 R[ebp] + R[edx] * 4 + 8 赋给寄存器 %eax,此时寄存器中存储的信息为操作数的地址 R[eax]

3.3.2 定点算数运算指令

加减乘除分别为:add, sub, mul, div,都是将「源地址」的运算到「目的地址」,即左边对应的值运算到右边对应的值。可以记忆为右边的数 +=, -=, *=, /=

3.3.3 按位运算指令

常用的有按位运算,以及算数、逻辑的左移和右移。按位运算中有一个用于判定单个数情况的指令为 testtest 不修改右边对应的值,其余都遵循上述「左的运算到右进而改变右」的规则。

移位指令大约有:shl, sal, shr, sar,分别为逻辑左移、算数左移、逻辑右移、算数右移。

3.3.4 控制转移指令

常用的有无条件转移,对应 C 语言中的 goto 关键字,汇编表示为 jmp 等。以及有条件转移,比如 je, jne 等,取决于逻辑运算的结果。转移的位置统称为「目标地址」。

3.4 C 语言程序的机器级表示​

用任何汇编语言或高级语言编写的源程序都必须翻译(汇编、解释或编译)成以指令形式表示的机器语言,才能在计算机上运行。本节高级语言选用 C 语言,机器语言选用 IA-32 指令系统,简单介绍高级语言转化为机器语言的过程。

3.4.1 过程调用的机器级表示​

CALL 调用指令:在执行调用过程之前需要将返回地址压入栈

RET 返回指令:在返回调用过程之前从栈中取出返回地址

过程调用的执行步骤(记调用者为 P,被调用者为 Q)

步骤过程
P 将参数存储到 Q 可以访问到的地方按参数列表从右往左依次压入栈
P 保存返回地址(即调用函数的下一个语句)并将控制权给 P将返回地址入栈并执行 CALL 指令
Q 保存 P 的现场并为自己的非静态变量分配空间一般由调用与被调用过程共同保存通用寄存器中的现场
执行 Q 的函数体
Q 恢复 P 的现场并释放局部变量空间恢复现场并释放局部变量空间
Q 取出返回地址并将控制权给 P通过 RET 指令

3.4.2 选择语句的机器级表示

主要由常用指令组合而成,相比于过程调用更加简单。

3.4.3 循环语句的机器级表示

同选择语句。

3.5 复杂数据类型的分配和访问

3.5.1 数组的分配与访问

采用相对寻址,大一学习 C 语言的数组时就学过相关概念了,关键有两点:

  • 知道数组存的是什么类型的数据(进而知道每一个元素占用多少字节)
  • 知道数组首地址。以二维数组为例,运算公式就是 &a + (typeof a) * (i * M) + j,其中 typeof a 就表示单个元素的字节数,i, j 就表示下标索引,M 就表示行数(默认按行优先规则)

3.5.2 结构体数据的分配与访问

采用首地址+偏移的逻辑分配与访问数据,与数组类似。同时,一个结构体存储所有字段的数据。

3.5.3 联合体数据的分配与访问

同样采用首地址+偏移的方式分配和访问数据。一个联合体在同一时刻只存储一种数据类型。即相同的位序列可以在不同时刻存储不同的数据类型变量。

3.5.4 数据的对齐

对于 32 位 OS 而言,存储器一行存储 32 位共 4 个字节,计算机系统一次访问存储空间只能读写固定长度的位。假定最多可以读写 64 位,则称为 8 字节宽的存储器机制。一般采用按边界对齐的对齐策略,尽管这样会浪费一些存储空间,但是有利于数据的快速访问。

一、对齐实例。对于 5 个变量 int a; short b; double c; char d; short e;

按边界对齐:

按边界对齐

边界不对齐

边界不对齐

二、结构体实例。全部按 4 字节对齐,末尾可能有插空

1
2
3
4
5
6
struct A {
int i;
short si;
char c;
double d;
};

1

1
2
3
4
5
6
struct B {
int i;
short si;
double d;
char c;
};

2

3.6 越界访问和缓冲区溢出

越界访问和缓冲区溢出其实是一个东西,只不过缓冲区溢出是单独针对栈空间中对局部数组进行了越界访问。一般而言的越界访问往往表示错误访问了栈空间中的返回地址导致程序出现意想不到的错误。

第4章 程序的链接

在编写大型程序时,为了提升效率以及不断复用,往往需要模块化开发,这就需要我们对多个分散的源代码片段进行整合。目前的整合方法是:首先对每一个源代码片段 (*.c)\text{(*.c)} 分开进行编译、汇编生成对应的可重定位目标文件 (*.o 或 *.obj)\text{(*.o 或 *.obj)},然后通过链接器 (linker)\text{(linker)} 将所有的可重定位目标文件与使用到的其他库的可重定位目标文件整合形成最终的可执行文件 (*.exe)\text{(*.exe)}。其中库文件包含静态库 archive 文件 (*.a)\text{(*.a)} 和动态共享库 shared object 文件 (*.so)(\text{*.so})

本章首先通过简单介绍源代码向可执行文件转换的过程,引出符号解析和重定位的粗略含义。然后详细介绍 UNIX 系统中可重定位目标文件和可执行文件的 ELF 格式。最后详细介绍可重定位目标文件中关于符号定义和符号引用到底是如何解析的,以及链接生成可执行文件的过程中,这些符号又是如何重定位到可执行文件中的。

4.1 编译、汇编和静态链接

模块化的好处:

  • 便于维护与共享。一个地方出问题了修改后重新编译即可,共享也很好理解。
  • 时间效率高。原因同上,不需要全部修改,单点修改即可解决。

4.2 目标文件格式

4.2.1 ELF 目标文件格式

Executable and Linkable Format,简称 ELF。是 UNIX 系统下对可重定位目标文件 (*.o)\text{(*.o)}、可执行目标文件 (*.exe)\text{(*.exe)} 和共享库文件 *.so\text{*.so} 的规范化格式,本目只介绍前两者的格式信息。

4.2.2 可重定位目标文件格式

(*.o 或 *.obj)\text{(*.o 或 *.obj)} 文件。

4.2.3 可执行目标文件格式

(*.exe)\text{(*.exe)} 文件。

4.3 符号解析

4.3.1 符号和符号表

每一个可重定位文件 demo.o 都有一个符号表用来记录本模块的符号信息

4.3.2 符号解析

符号解析的目的:将本模块中引用的符号与某个目标模块中的定义符号建立关联

建立关联的原理:每个定义符号在代码段或数据段中都被分配了存储空间,将引用符号与定义符号建立关联后,就可在重定位时将引用符号的地址重定位为相关联的定义符号的地址

链接器符号:

  • Global symbols(模块内部定义的全局符号)
  • External symbols(外部定义的全局符号)
  • Local symbols(本模块的局部符号)

(1)全局符号的强、弱特性

  • 强符号:函数的定义、已初始化的全局变量
  • 弱符号:函数的声明、未初始化的全局变量

符号解析定义的规则:由于每个符号仅占一处存储空间,因此有以下规则

  • 强符号不能多次定义
  • 若一个符号被定义为一次强符号和多次弱符号,则按强定义为准
  • 若有多个弱符号定义,则任选其中一个

(2)符号解析过程

4.3.3 与静态库的链接

按照符号引用顺序进行静态链接。引用在前,定义在后。

4.4 重定位

只关心全局变量和静态模块变量,对于所有的局部变量全部忽略。

4.4.1 重定位信息

4.4.2 重定位过程

(一)相对重定位方式 R_386_PC32

根据相对位置进行简单的加减运算得出相对的虚拟地址信息。

(二)绝对重定位方式 R_386_32

直接给出引用信息的虚拟内存地址。


至此本课程介绍的内容已全部结束,但其实还有动态链接值得玩味,我大致梳理一下脉络。所谓的静态库解决了代码复用的问题,但也有不足之处,假如磁盘中成千上万个可执行文件都引用了静态库中的某个文件,那么显然就会造成大量存储空间的浪费。同时,假如这些可执行文件同时运行,那么内存中又会读入上述被引用文件的大量重复副本。无论是存储空间还是内存,都是极大的浪费,而动态共享库很好的解决了这个问题,用到哪个库文件直接动态调用不就好了!除此之外,假如我需要维护升级某个静态库文件,那么那些引用该静态库文件的程序都需要重新链接进行更新,这显然是及其不方便且容易出错的。

]]>
@@ -1073,11 +1073,11 @@ - DataStructure - - /GPA/3rd-term/DataStructure/ + DataStructureClassDesign + + /GPA/3rd-term/DataStructureClassDesign/ -

score:\mathscr {score:}

  • 平时 20%(出勤、作业、实验)
  • 期中 20%
  • 期末 60%

数据结构

完整实现代码:https://github.com/Explorer-Dong/DataStructure

一、绪论

1.1 数据分析+结构存储+算法计算

1.1.1 逻辑结构

对于当前的数据之间的关系进行分析,进而思考应该如何存储,有以下几种逻辑结构:集合、线性结构、树形结构、图结构。

1.1.2 存储结构

设计一定的方法存储到程序中,需要思考:存什么?怎么存?

  • 存什么?数值存储、数据与数据之间关系域的存储
  • 怎么存?顺序存储、链式存储、树形存储、图存储

1.1.3 算法实现

设计算法计算实现

1.2 数据类型

约束:值集 + 运算集

数据类型 (Data Type, 简称 DT)\text{(Data Type, 简称 DT)}:一般编程语言已经实现好了

抽象数据类型 Abstract Data Type, 简称 ADT\text{Abstract Data Type, 简称 ADT}:数据结构 + 算法操作

ADT 的不同视图

1.3 算法方法

  1. 正确性

  2. 健壮性(鲁棒性):对于不合法、异常的输入也有处理能力

  3. 可读性

  4. 可扩展性

  5. 高效率

    1. 空间复杂度

    2. 时间复杂度 T(n)=O(f(n))T(n)=O(f(n)),其中有三种表示时间复杂度的公式

      • O()O() upper bound:最坏的时间复杂度
      • Ω()\Omega() lower bound:最好的时间复杂度
      • Θ()\Theta() average bound:平均时间复杂度

二、线性表

2.1 线性表的逻辑结构

有一个头结点,尾结点,且每一个结点只有一个前驱结点和一个后继结点。

2.2 线性表的存储结构

2.2.1 顺序存储结构

存储在一维地址连续的存储单元里

特点:逻辑位置相邻,物理位置也相邻

数据结构:一个一个一维数组 + 一个长度变量 n

1
2
3
4
5
6
7
8
template<class T, int MaxSize>
class SeqList
{
T data[MazSize];
int length;
public:
...
}

顺序表可以直接存储元素与关系。链表的元素存储也是可以直接实现的,但是关系要通过指针域来实现

2.2.2 链式存储结构

  1. 单链表:默认有一个头结点,不存储数据

  2. 循环链表

  3. 双向链表

2.3 线性表的操作算法

2.3.1 顺序表的操作算法

  1. 初始化构造

  2. 求顺序表长度

  3. 按位查找

  4. 按值查找

  5. 遍历顺序表

  6. 插入

  7. 删除

2.3.2 链表的操作算法

  1. 单链表初始化构造

    1
    2
    head = new Node<T>;
    head->next = nullptr;
    • 头插法

      1
      2
      head = new Node<T>;
      head->next = nullptr;
    • 尾插法

      1
      2
      head = new Node<T>;
      rear = head;
  2. 求单链表长度

  3. 按位查找

  4. 按值查找

  5. 遍历单链表

  6. 插入

  7. 删除

  8. 单链表的析构函数

  9. 其他操作

  10. 双向链表操作

    • 插入

      插入

      1
      2
      3
      4
      5
      // 插入当前结点 s
      s->prior = p;
      s->next = p->next;
      p->next->prior = s;
      p->next = s;
    • 删除

      删除

      1
      2
      3
      // 删除当前结点 p
      p->next->prior = p->prior;
      p->prior->next = p->next;

三、栈和队列

3.1 栈

3.1.1 栈的基本概念

卡特兰数

卡特兰数:假设 f(k)f(k) 表示第 k 个数最后一个出栈的总个数,则 f(k)=f(k1)f(nk)f(k)=f(k-1)f(n-k)

f(n)=k=1nf(k1)f(nk)=1n+1C2nnf(n) = \sum_{k=1}^{n} f(k-1) f(n-k)=\frac{1}{n+1} C_{2n}^{n}

3.1.2 栈的存储结构

顺序存储

顺序存储

链式存储

链式存储

3.1.3 栈的操作算法

  1. 顺序栈的操作
  2. 链栈的操作

3.1.4 栈的应用

  1. 括号匹配

  2. 算数表达式求值

    • 中缀表达式求值

      双栈思路,算符优先法

      • 遇到数字,直接入数栈

      • 遇到符号

        • 如果是括号,左括号直接入栈,右括号进行运算直到遇到左括号
        • 如果是算符,在入算符栈之前,需要进行运算操作直到算符栈顶元素等级小于当前算符等级
    • 中缀表达式转后缀表达式

      算符栈即可

      后缀先遇到就直接计算的运算符 \to 中缀表达式需要先算的运算符,于是转化思路就是:

      • 遇到数字,直接构造后缀表达式
      • 遇到算符
        • 如果是括号,左括号直接入栈,右括号进行后缀表达式构造直到遇到左括号
        • 如果是算符,在入算符栈之前,需要进行后缀表达式构造操作直到算符栈顶元素等级小于当前算符等级
    • 后缀表达式求值

      数栈即可

      遇到数字直接入数栈,遇到算符直接进行运算

  3. 栈与递归

    递归工作栈

3.2 队列

3.2.1 队列的基本概念

先进先出

3.2.2 队列的存储结构

顺序存储

顺序存储 - 1

顺序存储 - 2

链式存储

链式存储

3.2.3 队列的操作算法

  1. 循环队列的操作

    循环队列的三个注意点

    • 解决假溢出:采用循环队列,即在入队的时候不是单纯的指针 +1,而是+1后 % MaxSize
    • 解决队空队满的冲突(真溢出):
      1. 浪费一个元素空间:测试rear+1是否==head,
      2. 设置一个辅助标志变量
      3. 设置一个计数器
    1. 初始化:头尾全部初始化为0
    2. 入队push
    3. 出队pop
    4. 取队头front
    5. 长度size
    6. 队空empty
  2. 链队列的操作

3.2.4 队列的应用

  1. 报数问题:报到 0 的出队,报到 1 的重新入队,求解出队顺序

  2. 迷宫最短路问题:开一个记忆数组 d[i][j]d[i][j] 表示从起点 (0,0)(0,0) 到终点 (i,j)(i,j) 点的最短路径的长度。可以将求最短路看做一个波心扩散的物理场景,队列中的每一个点都可以作为一个波心,从而实现“两点之间线段最短”的物理场景

    • 为什么用队列:逐层搜索,每次搜素到的点就是当前点可以搜索到的最短的点,先搜到的点先扩展,于是就是队列的数据结构
    • 为什么最短:对于每一个点探索到的点都是最短的点,最终的搜索出来的路径就是最短的路径

四、串

4.1 串的基本概念

由字符组成的串:子串、主串、位置

4.2 串的存储结构

4.2.1 串的顺序存储

使用固定长度的数组来存储,3种存储字符串长度的方法如下:

存储字符串长度的方法 - 1

存储字符串长度的方法 - 2

存储字符串长度的方法 - 3

4.2.2 串的链式存储

存储密度=串值所占的内存一个结点的总内存\text{存储密度} = \frac {\text{串值所占的内存}}{\text{一个结点的总内存}}

非压缩形式:一个结点存一个字符

1
2
3
4
5
// 存储密度为:1/9 (64位操作系统)
struct String {
char data;
String* next;
};

压缩形式(块链):一个结点存储指定长度的字符

1
2
3
4
5
6
// 存储密度为:4/12 (64位操作系统)
const int MaxSize = 4;
struct String {
char data[MaxSize];
String* next;
}

4.3 串的操作算法

4.3.1 串的基本操作算法

串连接、串比较、串拷贝

4.3.2 串的模式匹配

BF算法(Brute - Force)

BF算法(Brute - Force)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 返回匹配上的所有位置下标(下标从0开始)
vector<int> BF(string& s, string& t) {
vector<int> res;
int i = 0, j = 0, n = s.size(), m = t.size();

while (i < n && j < m) {
if (s[i] == t[j]) i++, j++;
else i = i - j + 1, j = 0;

if (j == m) {
res.emplace_back(i - j);
j = 0;
}
}

return res;
}

KMP算法

KMP算法

优化思想

先看暴力思想,我们需要每次将模式串 t 后移一位重新进行比较,其中浪费了已匹配的串数据,优化就是从这块已匹配的串数据入手。而已匹配的串数据就是模式串本身的串数据,因为我们可以直接从模式串本身入手。

初步猜想

根据模式串的性质,构造一个数表 next,存储模式串应该后移的指针数 k

算法实现

  1. 递推求 next 数组
  2. KMP 中 i 指针不回溯,j 回溯到 next[j]
1
2
3
4
5
6
7
8
9
10
11
12
// 求 next 数组下标从1开始
for (int i = 2, j = 0; i <= m; i++) {
while (j && t[i] != t[j + 1])
// 未匹配上则不断回溯
j = ne[j];

if (t[i] == t[j + 1])
// 匹配上了则j指针后移一位
j++;

ne[i] = j;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// KMP 匹配 下标从1开始
for (int i = 1, j = 0; i <= n; i++) {
while (j && news[i] != newt[j + 1])
// 未匹配上则不断回溯
j = ne[j];

if (news[i] == newt[j + 1])
// 匹配上了则j指针后移一位
j++;

if (j == m) {
// 匹配完全,则统计并且回溯
cnt++;
j = ne[j];
}
}

五、数组和特殊矩阵

5.1 数组

5.1.1 数组的基本概念

1
2
3
4
typedef int arr[m][n];
// 等价于
typedef int arr1[n];
typedef arr1 arr2[m];

5.1.2 数组的存储结构

  1. 行优先:按行存储
  2. 列优先:按列存储

可以按照下标的关系,只需要知道第一个元素的地址,通过矩阵的大小关系即可直接计算出 aija_{ij\cdots} 的地址

5.2 特殊矩阵的压缩存储

对于多个相同的非零元素只分配一个存储空间,对零元素不分配空间

5.2.1 对称矩阵的压缩存储

对称矩阵的压缩存储

假设现在有一个 n*n 的对称矩阵

存:行优先存储 data[n * (n + 1) / 2]

取:我们如果要取 data[i][j]

  • 对于上三角

    • i >= jdata[i * (i + 1) / 2 + j]

    • i < jdata[j * (j + 1) / 2 + i]

  • 对于下三角

    • i >= jdata[i * (i + 1) / 2 + j]

    • i < jdata[j * (j + 1) / 2 + i]

5.2.2 三角矩阵的压缩存储

三角矩阵的压缩存储

假设现在有一个 n*n 的三角矩阵(上三角或下三角为常数c)

存:行优先存储,常数 c 存储到最后 data[n * (n + 1) / 2 + 1]

5.2.3 对角矩阵的压缩存储

对角矩阵的压缩存储

假设现在有一个 n*n 的对角矩阵(围绕主对角线有数据,其余数据均为0)

5.2.4 稀疏矩阵的压缩存储

假设现在有一个 n*m 的稀疏矩阵(很多零的一个矩阵)

  1. 三元组顺序表

    按行存储两个信息,一个是非零元素的数值,还有一个是具体的坐标 (i, j)

  2. 十字链表

    定义两个指针数组,定义两个指针数组,存储行列的头指针即可 vector<CrossNode<T>*> cheads, rheads

六、广义表

6.1 广义表的概念

与以往的线性表的区别在于:线性表的元素只能是 DT 或 ADT。而对于广义表,元素还可以是一个广义表,即可递归结构。

表头、表尾。对于当前序列,第一个元素就是表头,其余元素的集合就是表尾

特点:层次结构、共享结构、递归结构

6.2 广义表的存储结构

6.2.1 广义表中结点的结构

采用联合结构体存储结点类型

采用联合结构体存储结点类型

6.2.2 广义表的存储结构

广义表的存储结构

6.3 广义表的操作算法

  • 直接递归法 - 原子直接操作,子表循环成原子进行操作
  • 减治法 - 先处理第一个元素(原子:直接操作 oror 子表:递归操作),最后递归操作剩余的元素

6.3.3 广义表的其他操作算法

复制广义表、计算广义表的长度、计算广义表的深度、释放广义表的存储空间

七、树和二叉树

7.1 树的概念和性质

7.1.1 树的定义

7.1.2 树的基本术语

  1. 结点的度和树的度
    • 结点的度:每一个结点孩子结点的数量
    • 树的度:一棵树中结点度数的最大值
  2. 孩子、双亲、兄弟结点
  3. 路径和路径长度
  4. 子孙结点和祖先结点
  5. 结点的层次和树的高度
  6. 有序树和无序树
    • 有序树:子集不可以随意交换
    • 无序树:子集可以随意交换
  7. 森林
    • 多棵树

7.1.3 树的基本性质

7.2 二叉树的概念和性质

7.2.1 二叉树的定义

7.2.2 二叉树的基本性质

  1. 根是第一层。第 ii 层最多有 2i12^{i - 1} 个结点

  2. 树中叶子结点的个数为 n0n_0,度数为2的结点的个数为 n2n_2。已知树的点数为 nn,边数为 mm,则 n=m+1n = m + 1。而 n=n0+n1+n2n=n_0+n_1+n_2m=n1+2n2m=n_1+2n_2,则 n0+n1+n2=n1+2n2+1n_0+n_1+n_2 = n_1+2n_2 +1,则

    n0=n2+1n_0=n_2 + 1

  3. 满二叉树:每一层都是满结点

    满二叉树

  4. 完全二叉树:对于一个 kk 层的二叉树,1k11\to k-1 都是满的,第 kk 层从左到右连接叶子结点

    完全二叉树

    结点数固定,则完全二叉树的形状唯一

    完全二叉树的性质

    ii 为奇数,且 i1i\neq1,则左兄弟就是 i1i-1

    ii 为偶数,则右兄弟就是 i+1i+1

7.3 二叉树的存储结构

7.3.1 二叉树的顺序存储结构

  • 对于一般的二叉树,将其转化为完全二叉树进行存储即可
  • 插入删除操作都不方便

7.3.2 二叉树的链式存储结构

7.4 二叉树的遍历

7.4.1 二叉树遍历的概念

7.4.2 二叉树遍历算法

  1. 先中后遍历
    1. 递归遍历
    2. 栈式遍历
  2. 层序遍历

7.4.3 二叉树的构造和析构

  1. 由含空指针标记的单个遍历序列构造二叉树

    可以从遍历的逻辑进行逆推。在遍历到空指针的时候输出一个编制符号,然后在构造的时候按照遍历序列进行递归构造即可,如图

    先序序列进行构造:按照遍历的思路来,对于先序序列而言,第一个元素一定是根元素,因此首先根据“当前局面”的第一个元素创建根结点,接着递归创建左子树和右子树即可。注意传递的序列起始下标是引用类型的变量

    示例

    示例

    中序序列进行构造:

    不可以,因为不能确定根节点以及左子树和右子树的部分

    后序序列进行构造:与上述先序序列进行构建的逻辑一致,只不过有一个小 trick,即我们从后序序列的最后一个元素开始创建,那么得到的第一个元素就是根结点的值,然后首先递归创建右子树,再递归创建左子树即可。同样需要注意的是传递参数时,序列起始下标是引用类型的变量

    与先序序列构造逻辑相同,只是递归的顺序需要调整一下

  2. 由两个遍历序列构造二叉树

    • 先+中:构造逻辑与上述带标记的序列构造逻辑几乎一致,只不过区别在于如何进行递归中参数的传递。传递的参数除了先序和中序的字符串,还有当前局面先序序列的起始下标与当前局面中序序列的起始下标,以及以当前序列进行构造时子树的结点个数。很容易就可以找到当前序列的根结点,接着就是利用很简单的下标关系得到上述的三个参数的过程,最后将新得到的三个参数传递给递归函数进行递归构建左右子树即可,当前的根结点是 pre[ipre]
    • 后+中:逻辑与上述一致,只不过当前的根结点是 post[ipost+n-1]
  3. 由顺序结构构造链式结构

  4. 拷贝构造

  5. 析构

7.5 二叉树的其他操作算法

  1. 计算二叉树的结点数
    • 有返回值的递归
    • 无返回值的递归
  2. 计算二叉树的高度
    • 有返回值的递归
    • 无返回值的递归
  3. 根据关键值查找结点
  4. 查找结点的父结点

7.6 线索二叉树

7.6.1 线索二叉树的概念

将空指针域用前驱 or 后继结点的地址进行覆盖

7.6.2 线索二叉树的存储结构

依旧是链式存储,只不过增加了结点域中的指针类型,分为链接类型Link与线索类型Thread

7.6.3 线索二叉树的操作算法

以中序线索化的二叉树为例,涉及到以下几种算法:

  1. 线索化:设置一个全局变量 pre,为了简化思维,我们可以将一个中序遍历的过程想象成一个线性结构。前驱为 pre,当前为 p

    • p 的左子树为空,则 p 的前驱为 pre
    • pre 的右子树为空,则 pre 的后继为 p
  2. 求后继结点和前驱结点

  3. 遍历

  4. 求父结点

    • 首先,若已知当前是左子树,则父结点一定是当前右孩子的中序前驱线索;若已知当前是右子树,则父结点一定是当前左孩子的中序前驱线索
    • 但是在未知当前结点的位置(未知左右子树)时,同时搜索两边的父结点,然后根据试探出来的父结点,特判父结点的子结点是否是当前结点即可

7.7 树的存储结构与算法

7.7.1 树的存储结构

  1. 多叉链表表示法:将每一个结点的子结点都预设置为一个定值(树的最大度数):浪费空间

  2. 孩子链表表示法:自顶向下存储边的信息

    1
    2
    3
    4
    5
    6
    7
    8
    9
    template<class T>
    struct CTBox {
    T data;
    CTNode* firstchild;
    };
    struct CTNode {
    int child;
    CTNode* next;
    };

    孩子链表表示法

  3. 双亲表示法:自下向上存储边的信息

    双亲表示法

  4. 孩子兄弟表示法:左结点存储孩子,右结点存储兄弟

7.7.2 树的操作算法

构造、计算树的高度、计算树中所有结点的度

7.8 哈夫曼树与哈夫曼编码

7.8.1 哈夫曼树的定义

树的路径长度:叶子结点到根结点的路径之和

  1. 树的带权路径长度 WPLWPL :叶子结点到根结点的路径之和 ×\times 叶子结点的权重,整体之和
  2. WPLWPL 最小的树就叫做哈夫曼树:对于一个结点序列n,每次选择其中的两个权值最小的两个结点进行合并,在进行了n-1次以后,得到的二叉树就是哈夫曼树
  3. 哈夫曼编码:
    • 编码:利用二叉树进行前缀编码 - 避免解码时的二义性
    • 解码:根据编码的二叉 trie 树,进行解码

7.8.2 操作算法

构造 Huffman 树、编码、解码

八、图

8.1 图的基本概念

8.1.1 图的定义

Graph=(V,E)Graph = (V,E)

完全无向图:edge=n(n1)/2edge=n(n-1)/2

完全有向图:edge=n(n1)edge=n(n-1)

8.1.2 图的基本术语

  • 带权图称为网

  • 无向图:连通图和连通分量

    • 连通图:每一个顶点之间都有路径可达
    • 连通分量:极大连通子图
  • 有向图:强连通图和强连通分量

    • 强连通图:每一个顶点之间都有路径可达
  • 强连通分量:极大强连通子图

8.2 图的存储结构

教材中的点编号统一从 00 开始

8.2.1 邻接矩阵

无向图的度:第 ii 行(列)的非标记数的个数

有向图的度:入度为第 ii 行的非标记数的个数;出度为第 ii 列的非标记数的个数

类定义:

邻接矩阵 - 1

邻接矩阵 - 2

8.2.2 邻接表

存储出边表称为邻接表,存储入编表称为逆邻接表

类定义:

邻接表

8.3 图的遍历

8.3.1 图遍历的概念

每个结点只能访问一次,故需要开启标记数组用来记录是否访问的情况

8.3.2 深度优先搜索

深度优先搜索

邻接矩阵:

  • 时间复杂度:O(n2)O(n^2)

  • 针对邻接矩阵的一个无向连通图的搜索代码示例

    邻接矩阵的一个无向连通图的搜索代码示例

邻接表:

  • 时间复杂度:O(n+e)O(n+e)

  • 针对邻接表的一个无向连通图的搜索代码示例

    1
    2
    3
    4
    5
    6
    template<class T>
    void ALGraph::DFS(int v, bool* visited) {
    cout << vexs[v];
    visited[v] = true;
    // 遍历所有的边
    }

8.3.3 广度优先搜索

通过队列实现、时间复杂度与上述 DFS 算法类似

8.3.4 图遍历算法的应用

  1. (u,v) 的所有简单路径:dfs + 回溯法的简单应用

  2. 染色法求二部图:bfs 的简单应用。当然 dfs 也是可以的,只需要在染色之后判断是否有相同颜色的邻接点即可

8.4 最小生成树

8.4.1 最小生成树的概念及其性质

Minimum Spanning Tree(MST)Minimum\ Spanning\ Tree(MST)

证明:

最小生成树性质证明 - 图例

对于上述的一个割,选择其中权值最小的交叉边。从而对于所有的状态,每次选择最小交叉边即可。

8.4.2 Prim算法

算法标签:greedygreedy

  • 构造 n1n-1 个割的状态

  • 起始状态为:顶点集合 UU11 个顶点,顶点集合 VUV-Un1n-1 个顶点

  • 状态转移为:

    • 选完最小交叉边之后,将这条边在集合 VUV-U 中的顶点加入到最小生成树集合 UU
    • 更新最小交叉边数组 miniedges[ ]miniedges[\ ]
  • 时间复杂度:O(n2)O(n^2)

8.4.3 Kruskal算法

算法流标签:greedy,dsugreedy,dsu

  • 初始化 nn 个顶点作为 nn 个连通分量
  • 按照边的权值升序进行选择
    • 如果选出的边的两个顶点不在同一个集合,则加入最小生成树
    • 如果选出的边的两个顶点在同一个集合,则不选择(如果选了就会使得生成树形成回路)
  • 时间复杂度:O(eloge)O(e\log e)

8.5 最短路径

8.5.1 最短路径的概念

单源最短路

DijkstraDijkstra 算法无法求解含负边权的单源最短路

BellmanFordBellman-Ford 算法支持负边权的单源最短路求解

SpfaSpfa 算法同样支持负边权的单元最短路,属于 BellmanFordBellman-Ford 算法的优化

多源最短路

FloydFloyd 适用于求解含负边权的多源最短路

8.5.2 单源最短路径 - DijkstraDijkstra 算法

算法标签:greedygreedy dpdp

其实就是 PrimPrim 的另一种应用

  • PrimPrim 是只存储交叉边的最小值
  • DijkstraDijkstra 是存储交叉边的最小值 ++ 这条边在集合 S 中的点已经记录的值
  1. 朴素版:

    • 邻接矩阵

    • 定义 d[i]d[i] 表示从起点到当前i号点的最短路径的长度

    • 将顶点分为两个集合,SSVSV-S,其中 SS 表示已经更新了最短路径长度的顶点集合

    • 迭代更新过程:依次更新每一个结点,对于当前结点 viv_i,在集合 SS 中的所有结点中,选择其中到当前结点路径最短的顶点 vjv_j,则 d[i]=d[j]+edges[j][i]

    • 时间复杂度:O(n2)O(n^2)

  2. 堆优化:

    • 邻接表

    • 时间复杂度:O(eloge)O(e \log e)

8.5.3 多源最短路径 - FloydFloyd 算法

算法标签:dpdp

多阶段决策共 nn 个阶段,dp[i][j] 表示每一个阶段 kk,从 iijj 的选择前 kk 个顶点后的最短路径的长度

对于当前阶段 kk,我们利用阶段 k1k-1 的状态进行转移更新,其实就是对于新增加的顶点 vkv_k 是否选择的过程

  • 选择 vkv_k,则 dp[i][j] = dp[i][k] + dp[k][j]
  • 不选 vkv_k,则 dp[i][j] 就是 k1k-1 状态下的 dp[i][j]

8.6 AOV网与拓扑排序

8.6.1 有向无环图与AOV网的概念

  • 有向无环图:DAGDAG
  • AOV网: (activity on vertex network)(activity\ on\ vertex\ network)
  • 应用场景:在时间先后上有约束关系的工程管理问题

8.6.2 拓扑排序

  • 定义:顶点线性化
  • 应用:判环、判断一个图是否可以进行动态规划
  • 算法设计:对于有向图,从所有的入度为 0 的点开始删点删边,到最后判断有多少点被删除即可
  • 算法实现:可以采用 dfs 进行缩点删边,也可以采用 bfs 进行缩点删边
  • 时间复杂度:O(n+e)O(n+e)

九、查找

9.1 静态查找表

定义:只支持查询和修改,不支持删除与插入

9.1.1 顺序查找

9.1.2 折半查找

9.1.3 分块查找

结合顺序查找与分块查找的一种方法

分块查找

  • 索引表可以折半或者顺序查找
  • 块内部只能顺序查找

9.2 动态查找表

9.2.1 二叉排序树

定义:根结点比左子树所有结点的值都大,比右子树所有结点的值都小。关键字唯一

操作:查找、插入、删除

判定:想要判定一棵二叉树是否为二叉排序树,只需要判断中序遍历的结果是不是递增的即可,可以采取中序遍历序列比对的方法,也可以在递归遍历二叉树的过程中通过记录前驱结点的值直接进行比较判断。时间复杂度:O(n)O(n)

9.2.2 平衡二叉树

定义:平衡因子为左子树的高度 - 右子树的高度,平衡二叉树的平衡因子绝对值 <= 1

构建:当插入结点进行构建时出现了有结点平衡因子的绝对值超过了1,则进行“旋转”调整,旋转共分为4种

旋转 - LL、LR

旋转 - LR

旋转 - RL

尝试模拟一遍下列序列的构造过程就可以理解了

例题

9.3 Hash 查找

定义:装填因子 α=nm\alpha=\frac{n}{m},其中 nn 表示待填入表中的结点数,mm 表示哈希表的空间大小

哈希函数应该满足以下两点:第一、映射出来的地址不会越界;第二、映射出来的地址是唯一的

9.3.1 构造表

常用的哈希函数

  1. 直接地址法 - 线性函数一对一映射

    优点。计算简单且不可能产生冲突

    缺点。对于空间的要求极高,如果数据过于离散,则会造成很大的空间浪费

  2. 数字分析法 - 按照数位中的数值分布情况进行哈希

    缺点。需要预先知道数据的数字分布情况

  3. 平方取中法 - 对于 10m10^m 的哈希空间,可以将数字平方后取中间 mm 位进行哈希存储

  4. 折叠法

    • 移位法:将一个数字按照数位拆分为几个部分,然后将几个部分的数值累加出一个数即可,高位抹去不用

    • 间隔法:与移位法几乎一致,只不过将其中的部分意义间隔的进行数值反转,最后累计即可,高位抹去不用

  5. 除留余数法 - 按照数值 mod p\text{mod}\ p 后的数值进行哈希,假设哈希表空间大小为 mm ,则 pp 一般取 m\le m 的质数

处理冲突

  1. 开放定址法 - 探测开放地址,一般有三种
    • 连续序列进行线性探测
    • 左右倍增序列进行探测
    • 伪随机序列进行探测
    • 双 hash 探测法
  2. 拉链法
    • 定义:将产生 hash 冲突的元素放入同一个子集,通过单链表进行存储
    • 优点:没有堆积现象,从而减少了很多不必要的比价,提升比较效率;适合一开始不知道表长的情况;除结点更加容易。

9.3.2 查找表

按照构造相同的逻辑进行查找即可

十、排序

10.1 排序的基本概念

关键字:

  • 主关键字:每一个待排序的该关键字是独一无二的
  • 次关键字:每一个待排序的该关键字可能是重复的

稳定性:

  • 场景:只针对次关键字的情况
  • 稳定:按照次关键字排序后,原来相同关键字的顺序不变
  • 不稳定:按照次关键字排序后,原来相同关键字的顺序可能会改变

内外排序:

  • 内排序:数据全部存放在内存
  • 外排序:数据量过大时,待排序的数据在内存与外存之间不断转换

10.2 冒泡排序

稳定的。基于交换的思路进行

10.3 选择排序

  • 选择第 1 小的数放在第一个位置,…,选择第 i 小的数放在第 i 个位置

  • 共选择 n-1 次

10.4 插入排序

稳定的

  • 直接插入排序:依次向前缀已经排好序的序列中进行插入 - O(n2)O(n^2)
  • 折半插入排序:同上,只是选择插入位置的使用二分 - O(nlogn)O(n\log n)
  • 递归插入排序:排序 [1,i] 等价于先排好 [1,i-1],然后插入当前 num[i] 即可

10.5 希尔排序

不稳定

基于插入直接排序的优点:

  1. 当序列基本有序时,效率很高
  2. 当待排序数很少时,效率很高

于是希尔(Shell)就得出来以下的希尔排序算法:

  1. 将序列划分一定次数,从 d<n 到 1
  2. 每次划分都对组内的元素进行直接插入排序
  3. 最后分为 1 组时,直接排序一趟以后就可以得到 sortrd sequence

10.6 快速排序

不稳定

分治法三步骤:divide、conquer and combine

每次选择一个 pivot 进行 partition,递归两个 partition

1
2
3
4
5
6
7
8
9
10
11
12
void Sort(int l, int r) {
if (l >= r) return;

int i = l - 1, j = r + 1, x = a[l + r >> 1];
while (i < j) {
while (a[++i] < x);
while (a[--j] > x);
if (i < j) swap(a[i], a[j]);
}

Sort(l, j), Sort(j + 1, r);
}

10.7 堆排序

不稳定

堆与堆排序的定义:首先我们得知道什么是堆结构。堆是具有下面性质(对于任意的 1in/21\le i \le n/2 )的完全二叉树

  • kik2i,kik2i+1k_i \le k_{2i},k_i \le k_{2i+1} 叫做 小顶堆
  • kik2i,kik2i+1k_i \ge k_{2i},k_i \ge k_{2i+1} 叫做 大顶堆
  • 因此一个堆结构可以采用线性的单元进行存储与维护。而堆排序利用堆顶是最值这一性质,通过不断的取堆顶,调整堆的方式获得最终的排好序的序列

建立初始堆:由于完全二叉树中,每一个叶子结点都已经是堆结构,因此直接从第一个非叶子结点开始建堆即可。对每一个元素与左孩子、 右孩子进行比较

  • 如果当前结点的值比左右孩子都大,那么无需修改,当前位置就是堆顶
  • 如果当前结点的值比左孩子或者右孩子中的最大值小,则将最大的孩子作为堆顶,并将当前值不断的“下沉”即可

交换堆顶与记录位置后重新建堆:交换记录值获取当前堆中最值以后,需要将除了已记录的值的结点以外的所有结点重新调整为堆结构

  • 调整为堆结构的过程与上述初始建堆的过程完全一致,只是结点数每次 -1

时间复杂度:O(nlogn)O(n \log n)

10.8 归并排序

稳定的

递归:同样采用分治法,我们按照分治法的三个步骤进行讨论

  • divide:将当前序列划分为左右两部分
  • conquer:递归处理上述划分出来的两部分
  • combine:归并上述递归完的两部分

非递归:就是模拟上述递归的过程,可以拆分为三步

  • 归并
  • 按照指定的长度处理整个序列
  • 划分局部排序的长度

时间复杂度:O(nlogn)T(n)=2T(n2)+O(n)O(n \log n)\leftarrow T(n)=2T(\frac{n}{2}) + O(n)

]]>
+ 数据结构课程设计

效果演示

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

]]>
@@ -1094,11 +1094,11 @@ - DataStructureClassDesign - - /GPA/3rd-term/DataStructureClassDesign/ + CollegePhysics_2 + + /GPA/3rd-term/CollegePhysics_2/ - 数据结构课程设计

效果演示

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

]]>
+ 大学物理下

第九章 振动

9.1 简谐振动 振幅 周期和频率 相位

9.1.1 简谐振动

定义:加速度与位移成正比且方向相反,的运动叫做简谐振动

恢复力 FF

F=kxF=-kx

加速度 aa

a=Fm=kmxa=\frac{F}{m}=-\frac{k}{m}x

km\frac{k}{m} 代换为 ω2\omega^2,得到 aa :

a=d2xdt2=ω2xa = \frac{d^2x}{dt^2} =-\omega^2x

解得 xx 为:

x=Acos(ωt+ϕ)x=A\cos{(\omega t+\phi)}

xx 求导得 vv 为:

v=ωAsin(ωt+ϕ)v=-\omega A\sin{(\omega t+\phi)}

vv 求导得 aa 为:

a=ω2Acos(ωt+ϕ)a=-\omega^2A\cos{(\omega t+\phi)}

9.1.2 振幅

即上述的 AA

9.1.3 周期和频率

周期的定义:经历一次完全相同的振动经历的时间

正余弦振动周期为:

T=2πωT=\frac{2\pi}{\omega}

由于弹簧振子的 ω\omega

ω=km\omega=\sqrt{\frac{k}{m}}

故弹簧振子的周期为

T=2πmkT=2\pi\sqrt{\frac{m}{k}}

频率的定义:单位时间内完全振动的次数(周期的倒数),为

ν=1T=ω2π\nu=\frac{1}{T}=\frac{\omega}{2\pi}

从而推导出角频率(圆频率)ω\omega

ω=2πν\omega=2\pi\nu

9.1.4 相位

三角函数括号中的量

9.1.5 常量 AAϕ\phi 的确定

结合 x,v,ax,v,a 的方程组

{x=Acos(ωt+ϕ)v=ωAsin(ωt+ϕ)a=ω2Acos(ωt+ϕ)\begin{cases}x=A\cos{(\omega t+\phi)} \\v=-\omega A\sin{(\omega t+\phi)} \\a=-\omega^2A\cos{(\omega t+\phi)}\end{cases}

与初值

{t=0x=x0v=v0\begin{cases}t=0 \\x=x_0 \\v=v_0\end{cases}

可解得 A,ϕA,\phi

{A=x02+v02ω2tanϕ=v0ωx0\begin{cases}A=\sqrt{x_{0}^2+\frac{v_{0}^2}{\omega^2}} \\\tan \phi= \frac{-v_0}{\omega x_0}\end{cases}

9.2 旋转矢量

类似于单位圆,将一个单位向量旋转得到一个矢量

9.3 单摆和复摆

三类振动公式推导

力(力矩)加速度(角加速度)加速度(角加速度)角频率
弹簧振子F=kxF=-kxa=Fm=kmxa=\frac{F}{m}=-\frac{k}{m}xd2xdt2=a=kmx=ω2x\frac{d^{2}x}{dt^{2}}=a=-\frac{k}{m}x=-\omega^2xω=km\omega=\sqrt{\frac{k}{m}}
单摆M=Frsinθ=mglsinθ=mglθM=-Fr\sin{\theta}=-mgl\sin{\theta}=-mgl\thetaα=MJ=mglθml2=glθ\alpha=\frac{M}{J}=\frac{-mgl\theta}{ml^2}=-\frac{g}{l}\thetad2θdt2=α=glθ=ω2x\frac{d^{2}\theta}{dt^{2}}=\alpha=-\frac{g}{l}\theta=-\omega^2xω=gl\omega=\sqrt{\frac{g}{l}}
复摆M=Frsinθ=mglsinθ=mglθM=-Fr\sin{\theta}=-mgl\sin{\theta}=-mgl\thetaα=MJ=mglJθ\alpha=\frac{M}{J}=\frac{-mgl}{J}\thetad2θdt2=α=mglJθ=ω2x\frac{d^{2}\theta}{dt^{2}}=\alpha=-\frac{mgl}{J}\theta=-\omega^2xω=mglJ\omega=\sqrt{\frac{mgl}{J}}

简谐振动变量表述

振幅频率角频率周期初相
AAν\nuω\omegaTTϕ\phi

9.3.1 单摆

ω=gl, T=2πlg\omega=\sqrt{\frac{g}{l}}, \ T=2\pi \sqrt{\frac{l}{g}}

9.3.2 复摆

ω=mglJ,T=2πJmgl\omega=\sqrt{\frac{mgl}{J}}, T=2\pi\sqrt{\frac{J}{mgl}}

9.4 简谐振动的能量

  1. 系统动能

    Ek=12mv2=12mω2A2sin2(ωt+ϕ)E_k=\frac{1}{2}mv^2 = \frac{1}{2}m\omega^2A^2\sin^2{(\omega t+\phi)}

  2. 系统势能

    Ep=12kx2=12kA2cos2(ωt+ϕ)E_p = \frac{1}{2}kx^2 = \frac{1}{2}kA^2\cos^2{(\omega t+\phi)}

  3. 系统总能量

    E=Ek+Ep=12mω2A2sin2(ωt+ϕ)+12kA2cos2(ωt+ϕ)E = E_k+E_p =\frac{1}{2}m\omega^2A^2\sin^2{(\omega t+\phi)} + \frac{1}{2}kA^2\cos^2{(\omega t+\phi)}

    因为

    ω2=km\omega^2=\frac{k}{m}

    所以

    E=12kA2=12mω2A2E=\frac{1}{2}kA^2 = \frac{1}{2}m\omega^2A^2

9.5 简谐振动的合成

9.5.1 两个同方向同频率

根据「旋转矢量法」合成一个最终的矢量,根据三角函数可以计算出一般情况下的 tanϕ\tan{\phi},但是一般只考虑相位差为 2kπ2k\pi(2k+1)π(2k+1)\pi 的情况,最终得到 合振幅 CombineCombine 的取值范围为:

A1A2CombineA1+A2\left | A_1-A_2 \right | \le Combine \le A_1+A_2

9.5.2 两个垂直方向同频率

联立两个简谐运动方程,消去变量 t

9.5.3 两个同方向不同频率合成、拍

  • 只讨论:两个频率值很大,但是差值很小的情况

  • 的定义:由频率很大但频率之差很小的两个同方向简谐振动合成时,其合振动的振幅时而加强时而减弱的现象就叫做拍

  • 对于被合成的两个频率和一个最终合成的频率,可以通过旋转矢量法进行计算

  • 拍的周期 TT :

    T=2πω2ω1=1ω22πω12π=1ν2ν1T=\frac{2\pi}{\omega_2 - \omega_1}=\frac{1}{\frac{\omega_2}{2\pi}-\frac{\omega_1}{2\pi}}=\frac{1}{\nu_2-\nu_1}

  • 拍频就是 1T=ν2ν1\frac{1}{T} = \nu_2-\nu_1

【选学】9.7 电磁振荡

9.7.1 振荡电路 无阻尼自由电磁振荡

所谓振荡电路,就是电能(贮存在电容器)中的能量与磁场能(贮存在自感线圈)中的能量相互转化的电路,

而所谓的无阻尼自由电磁振荡,就是上述过程中,没有能量损失的电路。

9.7.2 无阻尼电磁振荡的方程

某一时刻电路中的电荷量 qq :

q=Q0cos(ωt+ϕ)q = Q_0 \cos {(\omega t + \phi)}

某一时刻电路中的电流 i=dqdti = \frac{dq}{dt} :

i=ωQ0sin(ωt+ϕ)i = -\omega Q_0 \sin{(\omega t + \phi)}

电路中的振荡角频率 ω\omega :

ω=1LC\omega = \frac{1}{\sqrt{LC}}

9.7.3 无阻尼电磁振荡的能量

电场能量 WeW_e :

We=q22C=Q022Ccos2(ωt+ϕ)W_e = \frac{q^2}{2C} = \frac{Q_0^2}{2C} \cos^2{(\omega t + \phi)}

磁场能量 WmW_m :

Wm=12Li2=12Lω2Q02sin2(ωt+ϕ)=Q022Csin2(ωt+ϕ)W_m = \frac{1}{2}Li^2 = \frac{1}{2}L\omega ^2 Q_0^2 \sin^2{(\omega t + \phi)} = \frac{Q_0^2}{2C} \sin^2{(\omega t + \phi)}

LC振荡电路总能量 WW :

W=We+Wm=Q022CW = W_e + W_m = \frac{Q_0^2}{2C}

第十章 波动

10.1 机械波的几个概念

10.1.1 机械波的形成

机械振动在弹性介质(固体、液体、气体)中传播就形成了机械波

10.1.2 横波与纵波

横波:波的传播方向与振动方向垂直的波

纵波:波的传播方向与振动方向平行的波

横波可以在固体中传播

纵波可以在固体、液体、气体中传播

10.1.3 波长 波的周期和频率 波速

波长:一个周期传播的距离

周期:波前进一个波长需要的时间

频率:单位时间内传播的完整波的个数,只取决于波源

波速:单位时间波传播的距离,只取决于介质

横、纵波在三种介质中的传播速度

固体液体气体
横波Gρ\sqrt{\frac{G}{\rho}}
纵波Eρ\sqrt{\frac{E}{\rho}}Kρ\sqrt{\frac{K}{\rho}}Kρ\sqrt{\frac{K}{\rho}}

符号说明

符号名称
GG切变模量
EE弹性模量
KK体积模量
ρ\rho介质密度

10.2 平面简谐波的波函数

10.2.1 平面简谐波的波函数

波函数=波动方程\text{波函数} = \text{波动方程}

假设一个波沿着x轴正方向传播,

距离原点O距离为x0x_0的点的振动方程为:

yQ=Acos(ωt+ϕ)y_Q =A\cos{(\omega t+ \phi)}

则距原点O距离为xx的点的波函数(波动方程)为:

y=Acos[ω(txx0u)+ϕ]y = A\cos{ \left [\omega(t - \frac{x-x_0}{u})+\phi \right ]}

10.2.2 波函数的物理含义

位移分布:对于一个位置 x,y 随 t 变化

振动规律:对于一个时刻 t,y 随 x 变化

相位的差:Δϕ=2πλΔx\Delta \phi = \frac{2\pi}{\lambda}\Delta x

做题技巧

针对求解波动方程与振动(运动)方程展开。

对于波动方程 y=y(x,t)y=y(x,t),假如给了一点A的振动方程 y=y(t)=Acos(ωt+ϕ)y=y(t)=A \cos (\omega t + \phi),我们需要求B,C两个点的振动方程,其中B点在A点传播方向的正方向距A点为b,C点在A点传播方向的负方向距A点为c,波速为u,则

  • 对于B点:在B点开始振动的时候A点已经开始振动了,因此当 t=0t=0 时,B点对应的时刻应该 <0<0,则 BB 点的波动方程为:

    yb=Acos[ω(tbu)]y_b=A \cos \left[ \omega(t - \frac{b}{u}) \right]

  • 对于C点:在C点开始振动的时候A点还未开始振动,因此当 t=0t=0 时,C点对应的时刻应该 >0>0,则 CC 点的波动方程为:

    yc=Acos[ω(t+cu)]y_c=A \cos \left[ \omega(t + \frac{c}{u}) \right]

如果需要求解某点的波动方程,则在求解出该点振动方程 y=Acos(ωt+ϕ)y=A \cos (\omega t + \phi) 后,将 ωt\omega t 扩展为 ω(txu)\omega\left(t\mp\frac{x}{u}\right) 即可。加还是取决于波的传播方向,遵循左加右减的原则

10.3 波的能量 能流密度

10.3.1 波动能量的传播

在振动的过程中,介质除了具有动能,还有因为发生形变而具有的势能

经过推导,体积元的 动能 = 势能,于是

体积元的总能量 dWdW 就为:

dW=(ρdV)A2ω2sin2ω(txu)dW = (\rho d V)A^2 \omega ^2 \sin ^2 \omega \left (t - \frac{x}{u} \right )

能量密度 ww 就为:

w=dWdV=ρA2ω2sin2ω(txu)w = \frac{dW}{dV} = \rho A^2 \omega ^2 \sin ^2 \omega \left (t - \frac{x}{u} \right )

平均能量密度 w\overline w 取一个周期就为:

w=12ρA2ω2\overline w = \frac{1}{2} \rho A^2 \omega ^2

10.3.2 能流和能流密度

能流:单位时间内垂直经过某一面积的能量

能流 PP 就为:

P=wuSP=wuS

平均能流 P\overline P 取一个周期就为:

P=wuS\overline P = \overline w uS

能流密度 II(垂直通过单位面积的平均能流,也称波的强度)就为:

I=PS=wu=12ρA2ω2uI=\frac{\overline P}{S} = \overline w u = \frac{1}{2} \rho A^2 \omega ^2u

10.4 惠更斯原理 波的衍射和干涉

10.4.1 惠更斯原理

波源的振动是通过介质中的质元依次传播出去的,因此每一个质元都可以看做一个新的波源

10.4.2 波的衍射

定义:波的衍射就是波绕过障碍物的边缘,在障碍物的几何阴影内继续传播的现象

现象:障碍物的宽度和波长差不多,衍射越明显,在此基础之上,宽度越小,衍射越明显

10.4.3 波的干涉

  1. 波的叠加原理:(只适合小振幅波动的线性叠加)
    • 相遇后,保持各自的特征继续传播
    • 相遇处的质点的位移为矢量和
  2. 波的干涉:
    • 定义:相干波(频率相同,振动方向平行,相位差恒定)相遇时,某些地方始终加强 or 减弱的现象
    • 对于**相位差恒定(不为零)**的两列波:
      • 加强点:相位差为 π\pi 的偶数倍
      • 减弱点:相位差为 π\pi 的奇数倍
    • 对于**相位差恒定(为零)**的两列波,简化为波程差的比较:
      1. 加强点:波程差为半波长的偶数倍
      2. 减弱点:波程差为半波长的奇数倍

10.5 驻波

10.5.1 驻波的产生

由两列振幅、频率、和波速相同的相干波,同向相遇时,特殊的干涉现象

10.5.2 驻波方程

对于上述的两列波的波动方程:

y1=Acos2π(νtxλ)y_1 = A \cos 2\pi \left ( {\nu t - \frac{x}{\lambda}} \right )

y2=Acos2π(νt+xλ)y_2 = A \cos 2\pi \left ( {\nu t + \frac{x}{\lambda}} \right )

合成的驻波方程为(余弦展开,有时需要配凑):

y(t)=y1+y2=(2Acos2πxλ)cos2πνty(t) = y_1 + y_2 = (2A \cos {2 \pi \frac{x}{\lambda}}) \cos 2 \pi \nu t

  1. 波节:始终静止不动的点
    • 位置:上述波动方程振幅为零的点,算出来为 14\frac 1 4 波长的奇数倍
    • 波节间距:半波长
  2. 波腹:振幅为 2A2A 的点
    • 位置:上述波动方程振幅为 2A2A 的点,算出来为 14\frac 1 4 波长的偶数倍
    • 波腹间距:半波长

10.5.3 相位跃变

波密反射回波疏时会导致相位变化 π\pi 的现象称为相位越变 π\pi ,也叫半波跃变

10.5.4 驻波的能量

驻波中能量始终在波节和波腹之间循环传播,因此驻波不传播能量

简述一下能量在驻波中的传播形式:

首先要意识到波节处的振幅始终为0

  • 当波腹处的振幅最大时,能量全部集中在波节处的势能中
  • 当波腹处的振幅最小时,能量全部集中在波腹处的动能中

10.5.5 振动的简正模式

对于两端固定的弦线来说,为了形成驻波,弦长 ll 应该为半波长的整数倍。而这些波的频率的集合称为弦振动的本征频率,最低频率称为基频,其余基频的整数倍n称为n次谐频

某个端口封闭的时候。形成的驻波在端口就是波节,因为空气相对于端口处的介质是波疏对波密介质

【选学】10.6 多普勒效应

简化为两句话

  • 无论是波源靠近接收器还是接收器靠近波源还是两者相对运动,归根结底是波在单位时间传播的距离发生了变化,我们只需要考虑单位时间内波的传播情况即可,最终得出的规律是

    ν=u±v0uvsν\nu' = \frac{u \pm v_0}{u \mp v_s} \nu

    式中,ν\nu' 为最终观察者接收到的波频,ν\nu 为波源的波频,uu 为波速,v0v_0 为观察者靠近(远离)波源的速度,vsv_s 为波源靠近(远离)观察者的速度,至于何时取正何时取负,脑子一转就知道了~

  • 当波源和观察者的相对运动不在同一条直线上时,将速度分解到同一条直线进行上述计算即可

第十一章 光学

11.1 相干光

定义:频率相同、振动方向相同、相位差恒定

11.2 杨氏双缝干涉 劳埃德镜

11.2.1 杨氏双缝干涉

就一个公式

Δx=ldλ\Delta x = \frac{l}{d}\lambda

式中,Δx\Delta x 为相邻两个明条纹之间的距离,ll 为相干光源距光频之间的距离,dd 为相干光源之间的距离,λ\lambda 为光波长

中央明纹两侧的明条纹分别为第一级,第二级,…,第 k 级明条纹

11.2.3 光程和光程差

  • 速度:

    uv=1n\frac{u}{v} = \frac{1}{n}

    式中:uu 为光在介质中的传播速度,vv 为光在真空中传播的速度,nn 为折射率

  • 路程:

    L=nLL' = nL

    式中:LL' 为光程,nn 为折射率,LL 为光在介质中传播的路程

  • 光程差

    • 光程差为半波长的偶数倍时,干涉加强
    • 光程差为半波长的奇数倍时,干涉减弱

【选学】11.2.5 劳埃德镜

利用镜面反射,使得一个点光源与其虚光源构成了一对相干光

需要注意的是:与机械波的半波损失类似,光波也有半波损失,需要考虑反射处的半波损失

11.3 薄膜干涉

11.3.1 薄膜干涉的光程差

考虑:

  1. 光程:光在折射率为 nn 的介质中传播的光程为 nLnL
  2. 相位跃变:考虑反射时可能产生的半波反射

补充:

  1. 使用透镜并不引起附加的光程差
  2. 应用在增透膜增反膜

11.4 劈尖 牛顿环 迈克尔孙干涉仪

判断干涉的关键在于计算光程差,下面两个例子从线性与非线性两个角度进行了计算光程差的演示

11.4.1 劈尖

干涉增强点:

2nd+λ2=2kλ(k=1,2,3,...)2nd+\frac{\lambda}{2} = 2k\lambda(k=1,2,3,...)

干涉减弱点:

2nd+λ2=(2k+1)λ(k=0,1,2,...)2nd+\frac{\lambda}{2} = (2k+1)\lambda(k=0,1,2,...)

其中,nn 为劈尖中空气的折射率,ndnd 为光程。由于劈尖玻璃的折射率 > 劈尖中空气的折射率,因此会有一个相位跃变

11.4.2 牛顿环

根据勾股定理计算光程差 Δ=2nd+λ2\Delta=2nd+\frac{\lambda}{2}

r2=R2(Rd)2=2Rdd2r^2=R^2-(R-d)^2=2Rd-d^2,由于 RdR \gg d,故可以近似为 2Rd2Rd

r=2Rd=(Δλ2)Rnr=\sqrt{2Rd}=\sqrt{(\Delta-\frac{\lambda}{2})\frac{R}{n}}

Δ\Delta 为半波长的奇数倍时,为暗条纹,此时的 r 为明环半径

Δ\Delta 为半波长的偶数倍时,为明条纹,此时的 r 为暗环半径

【选学】11.4.3 迈克尔孙干涉仪

11.5 光的衍射

11.5.1 光的衍射现象

11.5.2 惠更斯-菲涅耳原理

在一个波阵面上,某一点处的波振幅是各个子波相互叠加的结果

11.5.3 菲尼尔衍射和夫琅禾费衍射

11.6 夫琅禾费单缝衍射

单色光

单色光

复色光(白光)

复色光(白光)

分析

分析

  • 波带法:

    判断一束光中,某一方向的光经过透镜汇聚后呈现在光屏上的是暗条纹还是亮条纹。判断方法就是计算光透过透光孔的总光程差 bsinθb\sin\theta 能被完整的划分为几个半波长。

    • 若可以完整的划分为偶数个,则两两干涉抵消,最终中心为暗纹
    • 若可以完整的划分为奇数个,则两两抵消之后还剩一个,最终中心为明纹

    bsinθ={0,中央亮纹±2kλ2=±kλ,(k=1,2,)暗纹±(2k+1)λ2,(k=1,2,)亮纹其余情况,调和亮度b\sin \theta = \begin{cases}0,& \text{中央亮纹} \\\pm 2k \frac{\lambda}{2}= \pm k\lambda, (k=1,2,\cdots) & \text{暗纹}\\\pm (2k+1) \frac{\lambda}{2}, (k=1,2,\cdots) & \text{亮纹}\\\text{其余情况}, & \text{调和亮度}\end{cases}

  • 计算光屏上中央亮纹的宽度 dd

    1. 已知第一级暗纹的位置就是中央亮纹边界,于是此时 bsinθ=λb\sin \theta=\lambda,那么 sinθ=λb\sin \theta=\frac{\lambda}{b}

    2. 于是 x=ftanθfsinθ=λfbx=f\tan \theta \approx f\sin\theta=\frac{\lambda f}{b}

    3. 于是中央亮纹的宽度 d=2x=2λfbd=2x=\frac{2\lambda f}{b}

  • 求解相邻亮纹(暗纹)之间的距离:

    同上,只是不用乘2,那么按照上面的计算方法,相邻两个之间的距离就是 λfb\frac{\lambda f}{b}

11.7 夫琅禾费圆孔衍射 光学仪器的分辨本领

夫琅禾费圆孔衍射

  • 艾里斑满足的关系:

    2θ=df=2.44λD2\theta=\frac{d}{f} = 2.44\frac{\lambda}{D}

    其中:透镜光心张角 2θ2\theta,艾里斑直径 dd,透镜焦距 ff,光波波长 λ\lambda,圆孔直径 DD

  • 最小分辨角 θ0\theta_0

    θ0=1.22λD\theta_0=1.22 \frac{\lambda}{D}

  • 分辨本领:

    1θ0\frac{1}{\theta_0}

  • 瑞利判据:

    定义两个光源之间的夹角为 γ\gamma。若 γθ0\gamma\ge\theta_0 就可以分辨,反之无法分辨

  • 瑞利判据示例:

    左1能分辨(γ>θ0\gamma> \theta_0),左2恰好能分辨(γ=θ0\gamma=\theta_0),左3无法分辨(γ<θ0\gamma<\theta_0

    瑞利判据示例

11.8 衍射光栅

11.8.1 光栅

为了更精准的测量光波,需要产生亮纹:又窄又亮又稀疏,于是光栅就产生了

11.8.2 光栅衍射条纹的形成

明纹产生的位置公式

(b+b)sinθ=±kλ,k=0,1,2,...(b+b')\sin \theta= \pm k \lambda, k=0,1,2,...

其中:(b+b)sinθ(b+b')\sin \theta 为光栅上相邻两束光的光程差。

可以证明:光栅中狭缝条数越多,明纹就越亮越窄。

11.8.3 衍射光谱

衍射光谱

白光通过光栅后,中间产生中央明纹,边上的明纹会由于白光中的单色光波长不同而使得明纹产生了不同颜色的带状,由于波长越短衍射角越小,故对于一个波带,靠内侧的是紫光,靠外侧的是红光。

11.9 光的偏振性 马吕斯定理

11.9.1 自然光 偏振光

自然光经过偏振片后的光强减弱为原来的一半,即 I1=12I0I_1=\frac{1}{2}I_0 。因为可以将自然光各个方向的振动分解到两个互相垂直的方向上,于是两个互相垂直的方向上的光强就均分了总光强了

11.9.2 偏振片 起偏与检偏

  1. 起偏器和检偏器方向相同,则光线完全穿过
  2. 起偏器和检偏器方向垂直,则光线无法穿过
  3. 起偏器和检偏器方向介于上述两者之间,则部分穿过

11.9.3 马吕斯定律

定义:就是定量计算上述第三种情况的穿过的光强

推导:由于穿过之后的光的振幅分量 EE 变成了 E=E0cosαE=E_0\cos \alpha,且 II0=E2E02\frac{I}{I_0}=\frac{E^2}{E_0^2},则(其中 α\alpha 为起偏器与检偏器的夹角)

I=I0cosα2I=I_0\cos \alpha ^2

11.10 反射光和折射光的偏振

反射光和折射光的偏振

已知入射光线为自然光,入射角为 θ\theta,入射区域的折射率为 n1n_1,折射区域的折射率为 n2n_2。当上述入射角满足下式时:

tanθ=n2n1\tan \theta = \frac{n_2}{n_1}

  • 反射光线为完全偏振光
  • 反射光线与折射光线垂直

此时的入射角 θ\theta 称为布儒斯特角

第十二章 气体动理论

12.1 平衡态 理想气体物态方程 热力学第零定律

  • 理想气体物态方程:

    pV=NkTpV=νRTp=nkT\begin{aligned}pV&=NkT\\pV&=\nu RT\\p&=nkT\end{aligned}

    其中 pp 为气压,VV 为全部气体所占的体积

    NN 为全部体积下的气体分子数,ν\nu 为气体的物质的量

    kkRR 均为常数

    nn 为单位体积内的分子数,TT 为当前温度

  • 热力学第零定律:首先定义热平衡,即如果两个系统之间没有能量传递,则两系统达到了热平衡。那么热力学第零定律就是 A 与 B 达到了热平衡,B 与 C 达到了热平衡,则 A 与 C 也就处于热平衡状态

【选学】12.2 物质的微观模型 统计规律性

12.3 理想气体的压强公式

p=13nmv2p=23nεkp=13ρv2\begin{aligned}p&=\frac{1}{3} nm \overline{v^2}\\p&=\frac{2}{3}n\overline{\varepsilon_k} \\p&=\frac{1}{3}\rho\overline{v^2}\end{aligned}

其中 εk\overline{\varepsilon_k} 为气体分子的平均平动动能,ρ\rho 为气体密度,mm 为单个气体分子的质量

12.4 理想气体分子的平均平动动能与温度的关系

  • 理想气体分子的平均平动动能 εk\overline{\varepsilon}_k 与温度 TT 的关系:

    {p=nkTp=13nmv2εk=12mv2=32kT\begin{cases}p=nkT \\p=\frac{1}{3} nm \overline{v^2}\end{cases}\Longrightarrow \overline{\varepsilon}_k = \frac{1}{2}m\overline{v^2}=\frac{3}{2}kT

  • 方均根速率 vrmsv_{rms}

    12mv2=32kTvrms=3kTm\frac{1}{2}m\overline{v^2}=\frac{3}{2}kT \Longrightarrow v_{rms}=\sqrt{\frac{3kT}{m}}

    {pV=NkTpV=νRTvrms=3RTM\begin{cases}pV=NkT \\pV=\nu RT\end{cases}\Longrightarrow v_{rms}=\sqrt{\frac{3RT}{M}}

    其中 kkRR 均为常数,MM 为气体的摩尔质量

12.5 能量均分定理 理想气体的内能

12.5.1 自由度

定义:分子能量中速度和坐标的二次方项数

  • 单原子分子自由度:3(三项平动动能)
  • 刚性双原子分子:5(三项平动动能+两项转动动能)
  • 非刚性双原子分子:7(三项平动动能+两项转动动能+两项振动能量)

12.5.2 能量均分定理

ε=(t+r+v)12kT=i2kT\overline{\varepsilon}=(t+r+v)\frac{1}{2}kT=\frac{i}{2}kT

其中 ε\overline{\varepsilon} 为分子的平均能量,ii 为分子的自由度,t,r,vt,r,v 分别为平动、转动和振动中速度和坐标的二次方项数

12.5.3 理想气体的内能

E=νNAi2kTE=\nu N_A \frac{i}{2}kT

其中 EEν mol\nu\ \text{mol} 气体分子所含有的平均能量(内能),ii 为该气体的自由度。又

pV=NkTpV=νRT\begin{aligned}pV=NkT\\pV=\nu RT\end{aligned}

可得

NAk=RN_Ak=R

于是

E=νi2RTE=\nu\frac{i}{2}RT

【选学】12.6 麦克斯韦气体分子速率分布律

数学形式:

f(v)=4π(m2πkT)32eεkkTv2f(v)=4\pi \left( \frac{m}{2\pi kT} \right)^{\frac{3}{2}} e^{\frac{-\varepsilon_k}{kT}}v^2

三种统计速率:

  1. 最概然速率 vpv_p

    vp=2kTm=2RTMv_p=\sqrt{\frac{2kT}{m}}=\sqrt{\frac{2RT}{M}}

  2. 平均速率 v\overline{v}

    v=8kTπm=8RTπM\overline{v}=\sqrt{\frac{8kT}{\pi m}}=\sqrt{\frac{8RT}{\pi M}}

  3. 方均根速率 vrmsv_{rms}

    vrms=3kTm=3RTMv_{rms}=\sqrt{\frac{3kT}{m}}=\sqrt{\frac{3RT}{M}}

大小关系:

大小关系

第十三章 热力学基础

13.1 准静态过程 功 热量

  • 准静态过程:变化过程看做平衡过程
  • 功:系统做功是一个过程量

13.2 热力学第一定律 内能

  • 热力学第一定律(能量守恒):系统从外界吸收的热量,一部分用来对外界做功,一部分用来增加系统的内能
  • 内能:系统的内能只与系统的初末状态有关,而与过程无关

13.3 理想气体的等容过程和等压过程 摩尔热容

13.3.1 等容过程 摩尔定容热容

定义:系统吸收(放出)的热量全部用来增加(减少)系统的内能

计算 ν mol气体\nu\ \text{mol气体} 内能变化的公式利用 摩尔定容热容CV,m\text{摩尔定容热容} C_{V,m} 就很显然了

ΔQ=νCV,mΔT=ΔE\Delta Q=\nu C_{V,m} \Delta T=\Delta E

13.3.2 等压过程 摩尔定压热容

定义:系统吸收(放出)的热量一部分用来增加(减少)系统的内能,一部分用来对外做功(外界对系统做功)

计算 ν mol气体\nu\ \text{mol气体} 内能变化的公式利用 摩尔定压热容CV,m\text{摩尔定压热容} C_{V,m} 就很显然了

ΔQ=νCp,mΔT=ΔE+ΔW\Delta Q=\nu C_{p,m} \Delta T=\Delta E+\Delta W

* 摩尔定容热容与摩尔定压热容的关系

结合上面两式与 1mol1mol 的理想气体满足的式子 pV=RTpV=RT,可得

Cp,mCV,m=RC_{p,m}-C_{V,m}=R

这也就解释了,对于 1mol 的气体,吸收的热量,一部分用来增加内能,一部分用来对外做功。而这对外做功的热量就是 RΔTR\Delta T

13.3.3 比热容

  • 热容:C=dQdTC=\frac{dQ}{dT}
  • 比热容:c=Cmc=\frac{C}{m},其中 mm 为系统的质量

13.4 理想气体的等温过程和绝热过程

13.4.1 等温过程

定义:在恒温热源的环境下,系统的温度不变

性质:气体膨胀时,从恒温热源吸收的热量全部用来对外做功;气体压缩时,外界对气体做的功全部以热量的形式传递给恒温热源

13.4.2 绝热过程

定义:系统与外界没有热交换

绝热方程:

pVγ=const1Vγ1T=const2pγ1Tγ=const3\begin{aligned}pV^{\gamma} &= const_1 \\V^{\gamma - 1}T &= const_2 \\p^{\gamma - 1}T^{-\gamma} &= const_3\end{aligned}

其中

γ=Cp,mCV,m(γ>1)\gamma = \frac{C_{p, m}}{C_{V,m}}(\gamma > 1)

13.4.3 绝热线和等温线

绝热线和等温线

在 A 点处,绝热线的斜率比等温线的斜率绝对值来的更大,具体看推导

推导

【补充】13.3 与 13.4 小结

小结

13.5 循环过程 卡诺循环

13.5.1 循环过程

系统热功持续转换就需要一个循环过程。一个循环过程系统对外界所做的功为 p-V 图像中正循环(顺时针)包围的面积,一个循环结束之后,系统的内能没有改变

13.5.2 热机和制冷机

正循环:做正循环的系统一般叫热机,主要代表将热量转化为功的机器。η\eta 为热机效率

正循环

η=WQ1=Q1Q2Q1=1Q2Q1\eta = \frac{W}{Q_1} = \frac{Q_1-Q_2}{Q_1} = 1-\frac{Q_2}{Q_1}

负循环:做负循环的系统一般叫制冷机,主要代表利用外界做功使热量由低处流向高处,从而获得低温的机器。ee 为制冷系数

负循环

e=Q2W=Q2Q1Q2e=\frac{Q_2}{W} = \frac{Q_2}{Q_1-Q_2}

13.5.3 卡诺循环

理想循环状态

正循环:

η=WQ1=Q1Q2Q1=1Q2Q1=1T2T1\eta = \frac{W}{Q_1} = \frac{Q_1-Q_2}{Q_1} = 1-\frac{Q_2}{Q_1} = 1-\frac{T_2}{T_1}

负循环:

e=Q2W=Q2Q1Q2=T2T1T2e=\frac{Q_2}{W} = \frac{Q_2}{Q_1-Q_2} = \frac{T_2}{T_1-T_2}

13.6 热力学第二定律的表述 卡诺定理

13.6.1 热力学第二定律的两种表述

在理解热力学第二定律之前,先回顾一下热力学第零和第一定律。热力学第零定律:理解为热传递;热力学第一定律:理解为能量守恒定律在热学中的应用。第一类永动机就是建立在热力学第一定律的反面上的,即创造一种热机,不需要从外界吸收热量或者消耗系统内部的内能而不断向外做功的过程。下面引入热力学第二定律的两种表述:

  1. 开尔文表述:不存在一种热机,能够从单一热源吸收热量对外做功而不放出热量给其他物体
  2. 克劳修斯表述:热量不可能从低温物体自动传到高温物体而不引起外界的变化

综合上述两种表述。我们知道两种表述是等价的,即二者相互满足且一方错误另一方也将错误。其中:

  1. 开尔文表述表明:热功的转化是有方向性的
  2. 克劳修斯表述表明:热量的传递是有方向性的

第二类永动机,即一种可以将单一热源的热量完全转化为功的而不会发生热量耗散的热机。

13.6.2 可逆过程与不可逆过程

定义:逆过程可以完全重复正过程的每一个状态的过程,就叫做可逆过程。

需要满足以下两个条件的才能成为可逆过程:

  1. 每一刻都是准静态过程
  2. 没有其他耗散力做功

世界上不存在绝对的可逆过程,可逆过程是理想化的模型。

13.6.3 卡诺定理

其实卡诺基于热力学第二定律给出了一个世界物理法则,即所有的热循环理想的效率都不会超过卡诺循环。以理想气体的热循环为例,所有的热机进行热循环时,循环效率 η\eta' 都不会超过卡诺热机循环效率 η\eta,即

ηη=1TcoolThoot\eta ' \le \eta = 1-\frac{T_{cool}}{T_{hoot}}

]]>
@@ -1115,11 +1115,11 @@ - CollegePhysics_2 - - /GPA/3rd-term/CollegePhysics_2/ + DataStructure + + /GPA/3rd-term/DataStructure/ - 大学物理下

第九章 振动

9.1 简谐振动 振幅 周期和频率 相位

9.1.1 简谐振动

定义:加速度与位移成正比且方向相反,的运动叫做简谐振动

恢复力 FF

F=kxF=-kx

加速度 aa

a=Fm=kmxa=\frac{F}{m}=-\frac{k}{m}x

km\frac{k}{m} 代换为 ω2\omega^2,得到 aa :

a=d2xdt2=ω2xa = \frac{d^2x}{dt^2} =-\omega^2x

解得 xx 为:

x=Acos(ωt+ϕ)x=A\cos{(\omega t+\phi)}

xx 求导得 vv 为:

v=ωAsin(ωt+ϕ)v=-\omega A\sin{(\omega t+\phi)}

vv 求导得 aa 为:

a=ω2Acos(ωt+ϕ)a=-\omega^2A\cos{(\omega t+\phi)}

9.1.2 振幅

即上述的 AA

9.1.3 周期和频率

周期的定义:经历一次完全相同的振动经历的时间

正余弦振动周期为:

T=2πωT=\frac{2\pi}{\omega}

由于弹簧振子的 ω\omega

ω=km\omega=\sqrt{\frac{k}{m}}

故弹簧振子的周期为

T=2πmkT=2\pi\sqrt{\frac{m}{k}}

频率的定义:单位时间内完全振动的次数(周期的倒数),为

ν=1T=ω2π\nu=\frac{1}{T}=\frac{\omega}{2\pi}

从而推导出角频率(圆频率)ω\omega

ω=2πν\omega=2\pi\nu

9.1.4 相位

三角函数括号中的量

9.1.5 常量 AAϕ\phi 的确定

结合 x,v,ax,v,a 的方程组

{x=Acos(ωt+ϕ)v=ωAsin(ωt+ϕ)a=ω2Acos(ωt+ϕ)\begin{cases}x=A\cos{(\omega t+\phi)} \\v=-\omega A\sin{(\omega t+\phi)} \\a=-\omega^2A\cos{(\omega t+\phi)}\end{cases}

与初值

{t=0x=x0v=v0\begin{cases}t=0 \\x=x_0 \\v=v_0\end{cases}

可解得 A,ϕA,\phi

{A=x02+v02ω2tanϕ=v0ωx0\begin{cases}A=\sqrt{x_{0}^2+\frac{v_{0}^2}{\omega^2}} \\\tan \phi= \frac{-v_0}{\omega x_0}\end{cases}

9.2 旋转矢量

类似于单位圆,将一个单位向量旋转得到一个矢量

9.3 单摆和复摆

三类振动公式推导

力(力矩)加速度(角加速度)加速度(角加速度)角频率
弹簧振子F=kxF=-kxa=Fm=kmxa=\frac{F}{m}=-\frac{k}{m}xd2xdt2=a=kmx=ω2x\frac{d^{2}x}{dt^{2}}=a=-\frac{k}{m}x=-\omega^2xω=km\omega=\sqrt{\frac{k}{m}}
单摆M=Frsinθ=mglsinθ=mglθM=-Fr\sin{\theta}=-mgl\sin{\theta}=-mgl\thetaα=MJ=mglθml2=glθ\alpha=\frac{M}{J}=\frac{-mgl\theta}{ml^2}=-\frac{g}{l}\thetad2θdt2=α=glθ=ω2x\frac{d^{2}\theta}{dt^{2}}=\alpha=-\frac{g}{l}\theta=-\omega^2xω=gl\omega=\sqrt{\frac{g}{l}}
复摆M=Frsinθ=mglsinθ=mglθM=-Fr\sin{\theta}=-mgl\sin{\theta}=-mgl\thetaα=MJ=mglJθ\alpha=\frac{M}{J}=\frac{-mgl}{J}\thetad2θdt2=α=mglJθ=ω2x\frac{d^{2}\theta}{dt^{2}}=\alpha=-\frac{mgl}{J}\theta=-\omega^2xω=mglJ\omega=\sqrt{\frac{mgl}{J}}

简谐振动变量表述

振幅频率角频率周期初相
AAν\nuω\omegaTTϕ\phi

9.3.1 单摆

ω=gl, T=2πlg\omega=\sqrt{\frac{g}{l}}, \ T=2\pi \sqrt{\frac{l}{g}}

9.3.2 复摆

ω=mglJ,T=2πJmgl\omega=\sqrt{\frac{mgl}{J}}, T=2\pi\sqrt{\frac{J}{mgl}}

9.4 简谐振动的能量

  1. 系统动能

    Ek=12mv2=12mω2A2sin2(ωt+ϕ)E_k=\frac{1}{2}mv^2 = \frac{1}{2}m\omega^2A^2\sin^2{(\omega t+\phi)}

  2. 系统势能

    Ep=12kx2=12kA2cos2(ωt+ϕ)E_p = \frac{1}{2}kx^2 = \frac{1}{2}kA^2\cos^2{(\omega t+\phi)}

  3. 系统总能量

    E=Ek+Ep=12mω2A2sin2(ωt+ϕ)+12kA2cos2(ωt+ϕ)E = E_k+E_p =\frac{1}{2}m\omega^2A^2\sin^2{(\omega t+\phi)} + \frac{1}{2}kA^2\cos^2{(\omega t+\phi)}

    因为

    ω2=km\omega^2=\frac{k}{m}

    所以

    E=12kA2=12mω2A2E=\frac{1}{2}kA^2 = \frac{1}{2}m\omega^2A^2

9.5 简谐振动的合成

9.5.1 两个同方向同频率

根据「旋转矢量法」合成一个最终的矢量,根据三角函数可以计算出一般情况下的 tanϕ\tan{\phi},但是一般只考虑相位差为 2kπ2k\pi(2k+1)π(2k+1)\pi 的情况,最终得到 合振幅 CombineCombine 的取值范围为:

A1A2CombineA1+A2\left | A_1-A_2 \right | \le Combine \le A_1+A_2

9.5.2 两个垂直方向同频率

联立两个简谐运动方程,消去变量 t

9.5.3 两个同方向不同频率合成、拍

  • 只讨论:两个频率值很大,但是差值很小的情况

  • 的定义:由频率很大但频率之差很小的两个同方向简谐振动合成时,其合振动的振幅时而加强时而减弱的现象就叫做拍

  • 对于被合成的两个频率和一个最终合成的频率,可以通过旋转矢量法进行计算

  • 拍的周期 TT :

    T=2πω2ω1=1ω22πω12π=1ν2ν1T=\frac{2\pi}{\omega_2 - \omega_1}=\frac{1}{\frac{\omega_2}{2\pi}-\frac{\omega_1}{2\pi}}=\frac{1}{\nu_2-\nu_1}

  • 拍频就是 1T=ν2ν1\frac{1}{T} = \nu_2-\nu_1

【选学】9.7 电磁振荡

9.7.1 振荡电路 无阻尼自由电磁振荡

所谓振荡电路,就是电能(贮存在电容器)中的能量与磁场能(贮存在自感线圈)中的能量相互转化的电路,

而所谓的无阻尼自由电磁振荡,就是上述过程中,没有能量损失的电路。

9.7.2 无阻尼电磁振荡的方程

某一时刻电路中的电荷量 qq :

q=Q0cos(ωt+ϕ)q = Q_0 \cos {(\omega t + \phi)}

某一时刻电路中的电流 i=dqdti = \frac{dq}{dt} :

i=ωQ0sin(ωt+ϕ)i = -\omega Q_0 \sin{(\omega t + \phi)}

电路中的振荡角频率 ω\omega :

ω=1LC\omega = \frac{1}{\sqrt{LC}}

9.7.3 无阻尼电磁振荡的能量

电场能量 WeW_e :

We=q22C=Q022Ccos2(ωt+ϕ)W_e = \frac{q^2}{2C} = \frac{Q_0^2}{2C} \cos^2{(\omega t + \phi)}

磁场能量 WmW_m :

Wm=12Li2=12Lω2Q02sin2(ωt+ϕ)=Q022Csin2(ωt+ϕ)W_m = \frac{1}{2}Li^2 = \frac{1}{2}L\omega ^2 Q_0^2 \sin^2{(\omega t + \phi)} = \frac{Q_0^2}{2C} \sin^2{(\omega t + \phi)}

LC振荡电路总能量 WW :

W=We+Wm=Q022CW = W_e + W_m = \frac{Q_0^2}{2C}

第十章 波动

10.1 机械波的几个概念

10.1.1 机械波的形成

机械振动在弹性介质(固体、液体、气体)中传播就形成了机械波

10.1.2 横波与纵波

横波:波的传播方向与振动方向垂直的波

纵波:波的传播方向与振动方向平行的波

横波可以在固体中传播

纵波可以在固体、液体、气体中传播

10.1.3 波长 波的周期和频率 波速

波长:一个周期传播的距离

周期:波前进一个波长需要的时间

频率:单位时间内传播的完整波的个数,只取决于波源

波速:单位时间波传播的距离,只取决于介质

横、纵波在三种介质中的传播速度

固体液体气体
横波Gρ\sqrt{\frac{G}{\rho}}
纵波Eρ\sqrt{\frac{E}{\rho}}Kρ\sqrt{\frac{K}{\rho}}Kρ\sqrt{\frac{K}{\rho}}

符号说明

符号名称
GG切变模量
EE弹性模量
KK体积模量
ρ\rho介质密度

10.2 平面简谐波的波函数

10.2.1 平面简谐波的波函数

波函数=波动方程\text{波函数} = \text{波动方程}

假设一个波沿着x轴正方向传播,

距离原点O距离为x0x_0的点的振动方程为:

yQ=Acos(ωt+ϕ)y_Q =A\cos{(\omega t+ \phi)}

则距原点O距离为xx的点的波函数(波动方程)为:

y=Acos[ω(txx0u)+ϕ]y = A\cos{ \left [\omega(t - \frac{x-x_0}{u})+\phi \right ]}

10.2.2 波函数的物理含义

位移分布:对于一个位置 x,y 随 t 变化

振动规律:对于一个时刻 t,y 随 x 变化

相位的差:Δϕ=2πλΔx\Delta \phi = \frac{2\pi}{\lambda}\Delta x

做题技巧

针对求解波动方程与振动(运动)方程展开。

对于波动方程 y=y(x,t)y=y(x,t),假如给了一点A的振动方程 y=y(t)=Acos(ωt+ϕ)y=y(t)=A \cos (\omega t + \phi),我们需要求B,C两个点的振动方程,其中B点在A点传播方向的正方向距A点为b,C点在A点传播方向的负方向距A点为c,波速为u,则

  • 对于B点:在B点开始振动的时候A点已经开始振动了,因此当 t=0t=0 时,B点对应的时刻应该 <0<0,则 BB 点的波动方程为:

    yb=Acos[ω(tbu)]y_b=A \cos \left[ \omega(t - \frac{b}{u}) \right]

  • 对于C点:在C点开始振动的时候A点还未开始振动,因此当 t=0t=0 时,C点对应的时刻应该 >0>0,则 CC 点的波动方程为:

    yc=Acos[ω(t+cu)]y_c=A \cos \left[ \omega(t + \frac{c}{u}) \right]

如果需要求解某点的波动方程,则在求解出该点振动方程 y=Acos(ωt+ϕ)y=A \cos (\omega t + \phi) 后,将 ωt\omega t 扩展为 ω(txu)\omega\left(t\mp\frac{x}{u}\right) 即可。加还是取决于波的传播方向,遵循左加右减的原则

10.3 波的能量 能流密度

10.3.1 波动能量的传播

在振动的过程中,介质除了具有动能,还有因为发生形变而具有的势能

经过推导,体积元的 动能 = 势能,于是

体积元的总能量 dWdW 就为:

dW=(ρdV)A2ω2sin2ω(txu)dW = (\rho d V)A^2 \omega ^2 \sin ^2 \omega \left (t - \frac{x}{u} \right )

能量密度 ww 就为:

w=dWdV=ρA2ω2sin2ω(txu)w = \frac{dW}{dV} = \rho A^2 \omega ^2 \sin ^2 \omega \left (t - \frac{x}{u} \right )

平均能量密度 w\overline w 取一个周期就为:

w=12ρA2ω2\overline w = \frac{1}{2} \rho A^2 \omega ^2

10.3.2 能流和能流密度

能流:单位时间内垂直经过某一面积的能量

能流 PP 就为:

P=wuSP=wuS

平均能流 P\overline P 取一个周期就为:

P=wuS\overline P = \overline w uS

能流密度 II(垂直通过单位面积的平均能流,也称波的强度)就为:

I=PS=wu=12ρA2ω2uI=\frac{\overline P}{S} = \overline w u = \frac{1}{2} \rho A^2 \omega ^2u

10.4 惠更斯原理 波的衍射和干涉

10.4.1 惠更斯原理

波源的振动是通过介质中的质元依次传播出去的,因此每一个质元都可以看做一个新的波源

10.4.2 波的衍射

定义:波的衍射就是波绕过障碍物的边缘,在障碍物的几何阴影内继续传播的现象

现象:障碍物的宽度和波长差不多,衍射越明显,在此基础之上,宽度越小,衍射越明显

10.4.3 波的干涉

  1. 波的叠加原理:(只适合小振幅波动的线性叠加)
    • 相遇后,保持各自的特征继续传播
    • 相遇处的质点的位移为矢量和
  2. 波的干涉:
    • 定义:相干波(频率相同,振动方向平行,相位差恒定)相遇时,某些地方始终加强 or 减弱的现象
    • 对于**相位差恒定(不为零)**的两列波:
      • 加强点:相位差为 π\pi 的偶数倍
      • 减弱点:相位差为 π\pi 的奇数倍
    • 对于**相位差恒定(为零)**的两列波,简化为波程差的比较:
      1. 加强点:波程差为半波长的偶数倍
      2. 减弱点:波程差为半波长的奇数倍

10.5 驻波

10.5.1 驻波的产生

由两列振幅、频率、和波速相同的相干波,同向相遇时,特殊的干涉现象

10.5.2 驻波方程

对于上述的两列波的波动方程:

y1=Acos2π(νtxλ)y_1 = A \cos 2\pi \left ( {\nu t - \frac{x}{\lambda}} \right )

y2=Acos2π(νt+xλ)y_2 = A \cos 2\pi \left ( {\nu t + \frac{x}{\lambda}} \right )

合成的驻波方程为(余弦展开,有时需要配凑):

y(t)=y1+y2=(2Acos2πxλ)cos2πνty(t) = y_1 + y_2 = (2A \cos {2 \pi \frac{x}{\lambda}}) \cos 2 \pi \nu t

  1. 波节:始终静止不动的点
    • 位置:上述波动方程振幅为零的点,算出来为 14\frac 1 4 波长的奇数倍
    • 波节间距:半波长
  2. 波腹:振幅为 2A2A 的点
    • 位置:上述波动方程振幅为 2A2A 的点,算出来为 14\frac 1 4 波长的偶数倍
    • 波腹间距:半波长

10.5.3 相位跃变

波密反射回波疏时会导致相位变化 π\pi 的现象称为相位越变 π\pi ,也叫半波跃变

10.5.4 驻波的能量

驻波中能量始终在波节和波腹之间循环传播,因此驻波不传播能量

简述一下能量在驻波中的传播形式:

首先要意识到波节处的振幅始终为0

  • 当波腹处的振幅最大时,能量全部集中在波节处的势能中
  • 当波腹处的振幅最小时,能量全部集中在波腹处的动能中

10.5.5 振动的简正模式

对于两端固定的弦线来说,为了形成驻波,弦长 ll 应该为半波长的整数倍。而这些波的频率的集合称为弦振动的本征频率,最低频率称为基频,其余基频的整数倍n称为n次谐频

某个端口封闭的时候。形成的驻波在端口就是波节,因为空气相对于端口处的介质是波疏对波密介质

【选学】10.6 多普勒效应

简化为两句话

  • 无论是波源靠近接收器还是接收器靠近波源还是两者相对运动,归根结底是波在单位时间传播的距离发生了变化,我们只需要考虑单位时间内波的传播情况即可,最终得出的规律是

    ν=u±v0uvsν\nu' = \frac{u \pm v_0}{u \mp v_s} \nu

    式中,ν\nu' 为最终观察者接收到的波频,ν\nu 为波源的波频,uu 为波速,v0v_0 为观察者靠近(远离)波源的速度,vsv_s 为波源靠近(远离)观察者的速度,至于何时取正何时取负,脑子一转就知道了~

  • 当波源和观察者的相对运动不在同一条直线上时,将速度分解到同一条直线进行上述计算即可

第十一章 光学

11.1 相干光

定义:频率相同、振动方向相同、相位差恒定

11.2 杨氏双缝干涉 劳埃德镜

11.2.1 杨氏双缝干涉

就一个公式

Δx=ldλ\Delta x = \frac{l}{d}\lambda

式中,Δx\Delta x 为相邻两个明条纹之间的距离,ll 为相干光源距光频之间的距离,dd 为相干光源之间的距离,λ\lambda 为光波长

中央明纹两侧的明条纹分别为第一级,第二级,…,第 k 级明条纹

11.2.3 光程和光程差

  • 速度:

    uv=1n\frac{u}{v} = \frac{1}{n}

    式中:uu 为光在介质中的传播速度,vv 为光在真空中传播的速度,nn 为折射率

  • 路程:

    L=nLL' = nL

    式中:LL' 为光程,nn 为折射率,LL 为光在介质中传播的路程

  • 光程差

    • 光程差为半波长的偶数倍时,干涉加强
    • 光程差为半波长的奇数倍时,干涉减弱

【选学】11.2.5 劳埃德镜

利用镜面反射,使得一个点光源与其虚光源构成了一对相干光

需要注意的是:与机械波的半波损失类似,光波也有半波损失,需要考虑反射处的半波损失

11.3 薄膜干涉

11.3.1 薄膜干涉的光程差

考虑:

  1. 光程:光在折射率为 nn 的介质中传播的光程为 nLnL
  2. 相位跃变:考虑反射时可能产生的半波反射

补充:

  1. 使用透镜并不引起附加的光程差
  2. 应用在增透膜增反膜

11.4 劈尖 牛顿环 迈克尔孙干涉仪

判断干涉的关键在于计算光程差,下面两个例子从线性与非线性两个角度进行了计算光程差的演示

11.4.1 劈尖

干涉增强点:

2nd+λ2=2kλ(k=1,2,3,...)2nd+\frac{\lambda}{2} = 2k\lambda(k=1,2,3,...)

干涉减弱点:

2nd+λ2=(2k+1)λ(k=0,1,2,...)2nd+\frac{\lambda}{2} = (2k+1)\lambda(k=0,1,2,...)

其中,nn 为劈尖中空气的折射率,ndnd 为光程。由于劈尖玻璃的折射率 > 劈尖中空气的折射率,因此会有一个相位跃变

11.4.2 牛顿环

根据勾股定理计算光程差 Δ=2nd+λ2\Delta=2nd+\frac{\lambda}{2}

r2=R2(Rd)2=2Rdd2r^2=R^2-(R-d)^2=2Rd-d^2,由于 RdR \gg d,故可以近似为 2Rd2Rd

r=2Rd=(Δλ2)Rnr=\sqrt{2Rd}=\sqrt{(\Delta-\frac{\lambda}{2})\frac{R}{n}}

Δ\Delta 为半波长的奇数倍时,为暗条纹,此时的 r 为明环半径

Δ\Delta 为半波长的偶数倍时,为明条纹,此时的 r 为暗环半径

【选学】11.4.3 迈克尔孙干涉仪

11.5 光的衍射

11.5.1 光的衍射现象

11.5.2 惠更斯-菲涅耳原理

在一个波阵面上,某一点处的波振幅是各个子波相互叠加的结果

11.5.3 菲尼尔衍射和夫琅禾费衍射

11.6 夫琅禾费单缝衍射

单色光

单色光

复色光(白光)

复色光(白光)

分析

分析

  • 波带法:

    判断一束光中,某一方向的光经过透镜汇聚后呈现在光屏上的是暗条纹还是亮条纹。判断方法就是计算光透过透光孔的总光程差 bsinθb\sin\theta 能被完整的划分为几个半波长。

    • 若可以完整的划分为偶数个,则两两干涉抵消,最终中心为暗纹
    • 若可以完整的划分为奇数个,则两两抵消之后还剩一个,最终中心为明纹

    bsinθ={0,中央亮纹±2kλ2=±kλ,(k=1,2,)暗纹±(2k+1)λ2,(k=1,2,)亮纹其余情况,调和亮度b\sin \theta = \begin{cases}0,& \text{中央亮纹} \\\pm 2k \frac{\lambda}{2}= \pm k\lambda, (k=1,2,\cdots) & \text{暗纹}\\\pm (2k+1) \frac{\lambda}{2}, (k=1,2,\cdots) & \text{亮纹}\\\text{其余情况}, & \text{调和亮度}\end{cases}

  • 计算光屏上中央亮纹的宽度 dd

    1. 已知第一级暗纹的位置就是中央亮纹边界,于是此时 bsinθ=λb\sin \theta=\lambda,那么 sinθ=λb\sin \theta=\frac{\lambda}{b}

    2. 于是 x=ftanθfsinθ=λfbx=f\tan \theta \approx f\sin\theta=\frac{\lambda f}{b}

    3. 于是中央亮纹的宽度 d=2x=2λfbd=2x=\frac{2\lambda f}{b}

  • 求解相邻亮纹(暗纹)之间的距离:

    同上,只是不用乘2,那么按照上面的计算方法,相邻两个之间的距离就是 λfb\frac{\lambda f}{b}

11.7 夫琅禾费圆孔衍射 光学仪器的分辨本领

夫琅禾费圆孔衍射

  • 艾里斑满足的关系:

    2θ=df=2.44λD2\theta=\frac{d}{f} = 2.44\frac{\lambda}{D}

    其中:透镜光心张角 2θ2\theta,艾里斑直径 dd,透镜焦距 ff,光波波长 λ\lambda,圆孔直径 DD

  • 最小分辨角 θ0\theta_0

    θ0=1.22λD\theta_0=1.22 \frac{\lambda}{D}

  • 分辨本领:

    1θ0\frac{1}{\theta_0}

  • 瑞利判据:

    定义两个光源之间的夹角为 γ\gamma。若 γθ0\gamma\ge\theta_0 就可以分辨,反之无法分辨

  • 瑞利判据示例:

    左1能分辨(γ>θ0\gamma> \theta_0),左2恰好能分辨(γ=θ0\gamma=\theta_0),左3无法分辨(γ<θ0\gamma<\theta_0

    瑞利判据示例

11.8 衍射光栅

11.8.1 光栅

为了更精准的测量光波,需要产生亮纹:又窄又亮又稀疏,于是光栅就产生了

11.8.2 光栅衍射条纹的形成

明纹产生的位置公式

(b+b)sinθ=±kλ,k=0,1,2,...(b+b')\sin \theta= \pm k \lambda, k=0,1,2,...

其中:(b+b)sinθ(b+b')\sin \theta 为光栅上相邻两束光的光程差。

可以证明:光栅中狭缝条数越多,明纹就越亮越窄。

11.8.3 衍射光谱

衍射光谱

白光通过光栅后,中间产生中央明纹,边上的明纹会由于白光中的单色光波长不同而使得明纹产生了不同颜色的带状,由于波长越短衍射角越小,故对于一个波带,靠内侧的是紫光,靠外侧的是红光。

11.9 光的偏振性 马吕斯定理

11.9.1 自然光 偏振光

自然光经过偏振片后的光强减弱为原来的一半,即 I1=12I0I_1=\frac{1}{2}I_0 。因为可以将自然光各个方向的振动分解到两个互相垂直的方向上,于是两个互相垂直的方向上的光强就均分了总光强了

11.9.2 偏振片 起偏与检偏

  1. 起偏器和检偏器方向相同,则光线完全穿过
  2. 起偏器和检偏器方向垂直,则光线无法穿过
  3. 起偏器和检偏器方向介于上述两者之间,则部分穿过

11.9.3 马吕斯定律

定义:就是定量计算上述第三种情况的穿过的光强

推导:由于穿过之后的光的振幅分量 EE 变成了 E=E0cosαE=E_0\cos \alpha,且 II0=E2E02\frac{I}{I_0}=\frac{E^2}{E_0^2},则(其中 α\alpha 为起偏器与检偏器的夹角)

I=I0cosα2I=I_0\cos \alpha ^2

11.10 反射光和折射光的偏振

反射光和折射光的偏振

已知入射光线为自然光,入射角为 θ\theta,入射区域的折射率为 n1n_1,折射区域的折射率为 n2n_2。当上述入射角满足下式时:

tanθ=n2n1\tan \theta = \frac{n_2}{n_1}

  • 反射光线为完全偏振光
  • 反射光线与折射光线垂直

此时的入射角 θ\theta 称为布儒斯特角

第十二章 气体动理论

12.1 平衡态 理想气体物态方程 热力学第零定律

  • 理想气体物态方程:

    pV=NkTpV=νRTp=nkT\begin{aligned}pV&=NkT\\pV&=\nu RT\\p&=nkT\end{aligned}

    其中 pp 为气压,VV 为全部气体所占的体积

    NN 为全部体积下的气体分子数,ν\nu 为气体的物质的量

    kkRR 均为常数

    nn 为单位体积内的分子数,TT 为当前温度

  • 热力学第零定律:首先定义热平衡,即如果两个系统之间没有能量传递,则两系统达到了热平衡。那么热力学第零定律就是 A 与 B 达到了热平衡,B 与 C 达到了热平衡,则 A 与 C 也就处于热平衡状态

【选学】12.2 物质的微观模型 统计规律性

12.3 理想气体的压强公式

p=13nmv2p=23nεkp=13ρv2\begin{aligned}p&=\frac{1}{3} nm \overline{v^2}\\p&=\frac{2}{3}n\overline{\varepsilon_k} \\p&=\frac{1}{3}\rho\overline{v^2}\end{aligned}

其中 εk\overline{\varepsilon_k} 为气体分子的平均平动动能,ρ\rho 为气体密度,mm 为单个气体分子的质量

12.4 理想气体分子的平均平动动能与温度的关系

  • 理想气体分子的平均平动动能 εk\overline{\varepsilon}_k 与温度 TT 的关系:

    {p=nkTp=13nmv2εk=12mv2=32kT\begin{cases}p=nkT \\p=\frac{1}{3} nm \overline{v^2}\end{cases}\Longrightarrow \overline{\varepsilon}_k = \frac{1}{2}m\overline{v^2}=\frac{3}{2}kT

  • 方均根速率 vrmsv_{rms}

    12mv2=32kTvrms=3kTm\frac{1}{2}m\overline{v^2}=\frac{3}{2}kT \Longrightarrow v_{rms}=\sqrt{\frac{3kT}{m}}

    {pV=NkTpV=νRTvrms=3RTM\begin{cases}pV=NkT \\pV=\nu RT\end{cases}\Longrightarrow v_{rms}=\sqrt{\frac{3RT}{M}}

    其中 kkRR 均为常数,MM 为气体的摩尔质量

12.5 能量均分定理 理想气体的内能

12.5.1 自由度

定义:分子能量中速度和坐标的二次方项数

  • 单原子分子自由度:3(三项平动动能)
  • 刚性双原子分子:5(三项平动动能+两项转动动能)
  • 非刚性双原子分子:7(三项平动动能+两项转动动能+两项振动能量)

12.5.2 能量均分定理

ε=(t+r+v)12kT=i2kT\overline{\varepsilon}=(t+r+v)\frac{1}{2}kT=\frac{i}{2}kT

其中 ε\overline{\varepsilon} 为分子的平均能量,ii 为分子的自由度,t,r,vt,r,v 分别为平动、转动和振动中速度和坐标的二次方项数

12.5.3 理想气体的内能

E=νNAi2kTE=\nu N_A \frac{i}{2}kT

其中 EEν mol\nu\ \text{mol} 气体分子所含有的平均能量(内能),ii 为该气体的自由度。又

pV=NkTpV=νRT\begin{aligned}pV=NkT\\pV=\nu RT\end{aligned}

可得

NAk=RN_Ak=R

于是

E=νi2RTE=\nu\frac{i}{2}RT

【选学】12.6 麦克斯韦气体分子速率分布律

数学形式:

f(v)=4π(m2πkT)32eεkkTv2f(v)=4\pi \left( \frac{m}{2\pi kT} \right)^{\frac{3}{2}} e^{\frac{-\varepsilon_k}{kT}}v^2

三种统计速率:

  1. 最概然速率 vpv_p

    vp=2kTm=2RTMv_p=\sqrt{\frac{2kT}{m}}=\sqrt{\frac{2RT}{M}}

  2. 平均速率 v\overline{v}

    v=8kTπm=8RTπM\overline{v}=\sqrt{\frac{8kT}{\pi m}}=\sqrt{\frac{8RT}{\pi M}}

  3. 方均根速率 vrmsv_{rms}

    vrms=3kTm=3RTMv_{rms}=\sqrt{\frac{3kT}{m}}=\sqrt{\frac{3RT}{M}}

大小关系:

大小关系

第十三章 热力学基础

13.1 准静态过程 功 热量

  • 准静态过程:变化过程看做平衡过程
  • 功:系统做功是一个过程量

13.2 热力学第一定律 内能

  • 热力学第一定律(能量守恒):系统从外界吸收的热量,一部分用来对外界做功,一部分用来增加系统的内能
  • 内能:系统的内能只与系统的初末状态有关,而与过程无关

13.3 理想气体的等容过程和等压过程 摩尔热容

13.3.1 等容过程 摩尔定容热容

定义:系统吸收(放出)的热量全部用来增加(减少)系统的内能

计算 ν mol气体\nu\ \text{mol气体} 内能变化的公式利用 摩尔定容热容CV,m\text{摩尔定容热容} C_{V,m} 就很显然了

ΔQ=νCV,mΔT=ΔE\Delta Q=\nu C_{V,m} \Delta T=\Delta E

13.3.2 等压过程 摩尔定压热容

定义:系统吸收(放出)的热量一部分用来增加(减少)系统的内能,一部分用来对外做功(外界对系统做功)

计算 ν mol气体\nu\ \text{mol气体} 内能变化的公式利用 摩尔定压热容CV,m\text{摩尔定压热容} C_{V,m} 就很显然了

ΔQ=νCp,mΔT=ΔE+ΔW\Delta Q=\nu C_{p,m} \Delta T=\Delta E+\Delta W

* 摩尔定容热容与摩尔定压热容的关系

结合上面两式与 1mol1mol 的理想气体满足的式子 pV=RTpV=RT,可得

Cp,mCV,m=RC_{p,m}-C_{V,m}=R

这也就解释了,对于 1mol 的气体,吸收的热量,一部分用来增加内能,一部分用来对外做功。而这对外做功的热量就是 RΔTR\Delta T

13.3.3 比热容

  • 热容:C=dQdTC=\frac{dQ}{dT}
  • 比热容:c=Cmc=\frac{C}{m},其中 mm 为系统的质量

13.4 理想气体的等温过程和绝热过程

13.4.1 等温过程

定义:在恒温热源的环境下,系统的温度不变

性质:气体膨胀时,从恒温热源吸收的热量全部用来对外做功;气体压缩时,外界对气体做的功全部以热量的形式传递给恒温热源

13.4.2 绝热过程

定义:系统与外界没有热交换

绝热方程:

pVγ=const1Vγ1T=const2pγ1Tγ=const3\begin{aligned}pV^{\gamma} &= const_1 \\V^{\gamma - 1}T &= const_2 \\p^{\gamma - 1}T^{-\gamma} &= const_3\end{aligned}

其中

γ=Cp,mCV,m(γ>1)\gamma = \frac{C_{p, m}}{C_{V,m}}(\gamma > 1)

13.4.3 绝热线和等温线

绝热线和等温线

在 A 点处,绝热线的斜率比等温线的斜率绝对值来的更大,具体看推导

推导

【补充】13.3 与 13.4 小结

小结

13.5 循环过程 卡诺循环

13.5.1 循环过程

系统热功持续转换就需要一个循环过程。一个循环过程系统对外界所做的功为 p-V 图像中正循环(顺时针)包围的面积,一个循环结束之后,系统的内能没有改变

13.5.2 热机和制冷机

正循环:做正循环的系统一般叫热机,主要代表将热量转化为功的机器。η\eta 为热机效率

正循环

η=WQ1=Q1Q2Q1=1Q2Q1\eta = \frac{W}{Q_1} = \frac{Q_1-Q_2}{Q_1} = 1-\frac{Q_2}{Q_1}

负循环:做负循环的系统一般叫制冷机,主要代表利用外界做功使热量由低处流向高处,从而获得低温的机器。ee 为制冷系数

负循环

e=Q2W=Q2Q1Q2e=\frac{Q_2}{W} = \frac{Q_2}{Q_1-Q_2}

13.5.3 卡诺循环

理想循环状态

正循环:

η=WQ1=Q1Q2Q1=1Q2Q1=1T2T1\eta = \frac{W}{Q_1} = \frac{Q_1-Q_2}{Q_1} = 1-\frac{Q_2}{Q_1} = 1-\frac{T_2}{T_1}

负循环:

e=Q2W=Q2Q1Q2=T2T1T2e=\frac{Q_2}{W} = \frac{Q_2}{Q_1-Q_2} = \frac{T_2}{T_1-T_2}

13.6 热力学第二定律的表述 卡诺定理

13.6.1 热力学第二定律的两种表述

在理解热力学第二定律之前,先回顾一下热力学第零和第一定律。热力学第零定律:理解为热传递;热力学第一定律:理解为能量守恒定律在热学中的应用。第一类永动机就是建立在热力学第一定律的反面上的,即创造一种热机,不需要从外界吸收热量或者消耗系统内部的内能而不断向外做功的过程。下面引入热力学第二定律的两种表述:

  1. 开尔文表述:不存在一种热机,能够从单一热源吸收热量对外做功而不放出热量给其他物体
  2. 克劳修斯表述:热量不可能从低温物体自动传到高温物体而不引起外界的变化

综合上述两种表述。我们知道两种表述是等价的,即二者相互满足且一方错误另一方也将错误。其中:

  1. 开尔文表述表明:热功的转化是有方向性的
  2. 克劳修斯表述表明:热量的传递是有方向性的

第二类永动机,即一种可以将单一热源的热量完全转化为功的而不会发生热量耗散的热机。

13.6.2 可逆过程与不可逆过程

定义:逆过程可以完全重复正过程的每一个状态的过程,就叫做可逆过程。

需要满足以下两个条件的才能成为可逆过程:

  1. 每一刻都是准静态过程
  2. 没有其他耗散力做功

世界上不存在绝对的可逆过程,可逆过程是理想化的模型。

13.6.3 卡诺定理

其实卡诺基于热力学第二定律给出了一个世界物理法则,即所有的热循环理想的效率都不会超过卡诺循环。以理想气体的热循环为例,所有的热机进行热循环时,循环效率 η\eta' 都不会超过卡诺热机循环效率 η\eta,即

ηη=1TcoolThoot\eta ' \le \eta = 1-\frac{T_{cool}}{T_{hoot}}

]]>
+

score:\mathscr {score:}

  • 平时 20%(出勤、作业、实验)
  • 期中 20%
  • 期末 60%

数据结构

完整实现代码:https://github.com/Explorer-Dong/DataStructure

一、绪论

1.1 数据分析+结构存储+算法计算

1.1.1 逻辑结构

对于当前的数据之间的关系进行分析,进而思考应该如何存储,有以下几种逻辑结构:集合、线性结构、树形结构、图结构。

1.1.2 存储结构

设计一定的方法存储到程序中,需要思考:存什么?怎么存?

  • 存什么?数值存储、数据与数据之间关系域的存储
  • 怎么存?顺序存储、链式存储、树形存储、图存储

1.1.3 算法实现

设计算法计算实现

1.2 数据类型

约束:值集 + 运算集

数据类型 (Data Type, 简称 DT)\text{(Data Type, 简称 DT)}:一般编程语言已经实现好了

抽象数据类型 Abstract Data Type, 简称 ADT\text{Abstract Data Type, 简称 ADT}:数据结构 + 算法操作

ADT 的不同视图

1.3 算法方法

  1. 正确性

  2. 健壮性(鲁棒性):对于不合法、异常的输入也有处理能力

  3. 可读性

  4. 可扩展性

  5. 高效率

    1. 空间复杂度

    2. 时间复杂度 T(n)=O(f(n))T(n)=O(f(n)),其中有三种表示时间复杂度的公式

      • O()O() upper bound:最坏的时间复杂度
      • Ω()\Omega() lower bound:最好的时间复杂度
      • Θ()\Theta() average bound:平均时间复杂度

二、线性表

2.1 线性表的逻辑结构

有一个头结点,尾结点,且每一个结点只有一个前驱结点和一个后继结点。

2.2 线性表的存储结构

2.2.1 顺序存储结构

存储在一维地址连续的存储单元里

特点:逻辑位置相邻,物理位置也相邻

数据结构:一个一个一维数组 + 一个长度变量 n

1
2
3
4
5
6
7
8
template<class T, int MaxSize>
class SeqList
{
T data[MazSize];
int length;
public:
...
}

顺序表可以直接存储元素与关系。链表的元素存储也是可以直接实现的,但是关系要通过指针域来实现

2.2.2 链式存储结构

  1. 单链表:默认有一个头结点,不存储数据

  2. 循环链表

  3. 双向链表

2.3 线性表的操作算法

2.3.1 顺序表的操作算法

  1. 初始化构造

  2. 求顺序表长度

  3. 按位查找

  4. 按值查找

  5. 遍历顺序表

  6. 插入

  7. 删除

2.3.2 链表的操作算法

  1. 单链表初始化构造

    1
    2
    head = new Node<T>;
    head->next = nullptr;
    • 头插法

      1
      2
      head = new Node<T>;
      head->next = nullptr;
    • 尾插法

      1
      2
      head = new Node<T>;
      rear = head;
  2. 求单链表长度

  3. 按位查找

  4. 按值查找

  5. 遍历单链表

  6. 插入

  7. 删除

  8. 单链表的析构函数

  9. 其他操作

  10. 双向链表操作

    • 插入

      插入

      1
      2
      3
      4
      5
      // 插入当前结点 s
      s->prior = p;
      s->next = p->next;
      p->next->prior = s;
      p->next = s;
    • 删除

      删除

      1
      2
      3
      // 删除当前结点 p
      p->next->prior = p->prior;
      p->prior->next = p->next;

三、栈和队列

3.1 栈

3.1.1 栈的基本概念

卡特兰数

卡特兰数:假设 f(k)f(k) 表示第 k 个数最后一个出栈的总个数,则 f(k)=f(k1)f(nk)f(k)=f(k-1)f(n-k)

f(n)=k=1nf(k1)f(nk)=1n+1C2nnf(n) = \sum_{k=1}^{n} f(k-1) f(n-k)=\frac{1}{n+1} C_{2n}^{n}

3.1.2 栈的存储结构

顺序存储

顺序存储

链式存储

链式存储

3.1.3 栈的操作算法

  1. 顺序栈的操作
  2. 链栈的操作

3.1.4 栈的应用

  1. 括号匹配

  2. 算数表达式求值

    • 中缀表达式求值

      双栈思路,算符优先法

      • 遇到数字,直接入数栈

      • 遇到符号

        • 如果是括号,左括号直接入栈,右括号进行运算直到遇到左括号
        • 如果是算符,在入算符栈之前,需要进行运算操作直到算符栈顶元素等级小于当前算符等级
    • 中缀表达式转后缀表达式

      算符栈即可

      后缀先遇到就直接计算的运算符 \to 中缀表达式需要先算的运算符,于是转化思路就是:

      • 遇到数字,直接构造后缀表达式
      • 遇到算符
        • 如果是括号,左括号直接入栈,右括号进行后缀表达式构造直到遇到左括号
        • 如果是算符,在入算符栈之前,需要进行后缀表达式构造操作直到算符栈顶元素等级小于当前算符等级
    • 后缀表达式求值

      数栈即可

      遇到数字直接入数栈,遇到算符直接进行运算

  3. 栈与递归

    递归工作栈

3.2 队列

3.2.1 队列的基本概念

先进先出

3.2.2 队列的存储结构

顺序存储

顺序存储 - 1

顺序存储 - 2

链式存储

链式存储

3.2.3 队列的操作算法

  1. 循环队列的操作

    循环队列的三个注意点

    • 解决假溢出:采用循环队列,即在入队的时候不是单纯的指针 +1,而是+1后 % MaxSize
    • 解决队空队满的冲突(真溢出):
      1. 浪费一个元素空间:测试rear+1是否==head,
      2. 设置一个辅助标志变量
      3. 设置一个计数器
    1. 初始化:头尾全部初始化为0
    2. 入队push
    3. 出队pop
    4. 取队头front
    5. 长度size
    6. 队空empty
  2. 链队列的操作

3.2.4 队列的应用

  1. 报数问题:报到 0 的出队,报到 1 的重新入队,求解出队顺序

  2. 迷宫最短路问题:开一个记忆数组 d[i][j]d[i][j] 表示从起点 (0,0)(0,0) 到终点 (i,j)(i,j) 点的最短路径的长度。可以将求最短路看做一个波心扩散的物理场景,队列中的每一个点都可以作为一个波心,从而实现“两点之间线段最短”的物理场景

    • 为什么用队列:逐层搜索,每次搜素到的点就是当前点可以搜索到的最短的点,先搜到的点先扩展,于是就是队列的数据结构
    • 为什么最短:对于每一个点探索到的点都是最短的点,最终的搜索出来的路径就是最短的路径

四、串

4.1 串的基本概念

由字符组成的串:子串、主串、位置

4.2 串的存储结构

4.2.1 串的顺序存储

使用固定长度的数组来存储,3种存储字符串长度的方法如下:

存储字符串长度的方法 - 1

存储字符串长度的方法 - 2

存储字符串长度的方法 - 3

4.2.2 串的链式存储

存储密度=串值所占的内存一个结点的总内存\text{存储密度} = \frac {\text{串值所占的内存}}{\text{一个结点的总内存}}

非压缩形式:一个结点存一个字符

1
2
3
4
5
// 存储密度为:1/9 (64位操作系统)
struct String {
char data;
String* next;
};

压缩形式(块链):一个结点存储指定长度的字符

1
2
3
4
5
6
// 存储密度为:4/12 (64位操作系统)
const int MaxSize = 4;
struct String {
char data[MaxSize];
String* next;
}

4.3 串的操作算法

4.3.1 串的基本操作算法

串连接、串比较、串拷贝

4.3.2 串的模式匹配

BF算法(Brute - Force)

BF算法(Brute - Force)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 返回匹配上的所有位置下标(下标从0开始)
vector<int> BF(string& s, string& t) {
vector<int> res;
int i = 0, j = 0, n = s.size(), m = t.size();

while (i < n && j < m) {
if (s[i] == t[j]) i++, j++;
else i = i - j + 1, j = 0;

if (j == m) {
res.emplace_back(i - j);
j = 0;
}
}

return res;
}

KMP算法

KMP算法

优化思想

先看暴力思想,我们需要每次将模式串 t 后移一位重新进行比较,其中浪费了已匹配的串数据,优化就是从这块已匹配的串数据入手。而已匹配的串数据就是模式串本身的串数据,因为我们可以直接从模式串本身入手。

初步猜想

根据模式串的性质,构造一个数表 next,存储模式串应该后移的指针数 k

算法实现

  1. 递推求 next 数组
  2. KMP 中 i 指针不回溯,j 回溯到 next[j]
1
2
3
4
5
6
7
8
9
10
11
12
// 求 next 数组下标从1开始
for (int i = 2, j = 0; i <= m; i++) {
while (j && t[i] != t[j + 1])
// 未匹配上则不断回溯
j = ne[j];

if (t[i] == t[j + 1])
// 匹配上了则j指针后移一位
j++;

ne[i] = j;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// KMP 匹配 下标从1开始
for (int i = 1, j = 0; i <= n; i++) {
while (j && news[i] != newt[j + 1])
// 未匹配上则不断回溯
j = ne[j];

if (news[i] == newt[j + 1])
// 匹配上了则j指针后移一位
j++;

if (j == m) {
// 匹配完全,则统计并且回溯
cnt++;
j = ne[j];
}
}

五、数组和特殊矩阵

5.1 数组

5.1.1 数组的基本概念

1
2
3
4
typedef int arr[m][n];
// 等价于
typedef int arr1[n];
typedef arr1 arr2[m];

5.1.2 数组的存储结构

  1. 行优先:按行存储
  2. 列优先:按列存储

可以按照下标的关系,只需要知道第一个元素的地址,通过矩阵的大小关系即可直接计算出 aija_{ij\cdots} 的地址

5.2 特殊矩阵的压缩存储

对于多个相同的非零元素只分配一个存储空间,对零元素不分配空间

5.2.1 对称矩阵的压缩存储

对称矩阵的压缩存储

假设现在有一个 n*n 的对称矩阵

存:行优先存储 data[n * (n + 1) / 2]

取:我们如果要取 data[i][j]

  • 对于上三角

    • i >= jdata[i * (i + 1) / 2 + j]

    • i < jdata[j * (j + 1) / 2 + i]

  • 对于下三角

    • i >= jdata[i * (i + 1) / 2 + j]

    • i < jdata[j * (j + 1) / 2 + i]

5.2.2 三角矩阵的压缩存储

三角矩阵的压缩存储

假设现在有一个 n*n 的三角矩阵(上三角或下三角为常数c)

存:行优先存储,常数 c 存储到最后 data[n * (n + 1) / 2 + 1]

5.2.3 对角矩阵的压缩存储

对角矩阵的压缩存储

假设现在有一个 n*n 的对角矩阵(围绕主对角线有数据,其余数据均为0)

5.2.4 稀疏矩阵的压缩存储

假设现在有一个 n*m 的稀疏矩阵(很多零的一个矩阵)

  1. 三元组顺序表

    按行存储两个信息,一个是非零元素的数值,还有一个是具体的坐标 (i, j)

  2. 十字链表

    定义两个指针数组,定义两个指针数组,存储行列的头指针即可 vector<CrossNode<T>*> cheads, rheads

六、广义表

6.1 广义表的概念

与以往的线性表的区别在于:线性表的元素只能是 DT 或 ADT。而对于广义表,元素还可以是一个广义表,即可递归结构。

表头、表尾。对于当前序列,第一个元素就是表头,其余元素的集合就是表尾

特点:层次结构、共享结构、递归结构

6.2 广义表的存储结构

6.2.1 广义表中结点的结构

采用联合结构体存储结点类型

采用联合结构体存储结点类型

6.2.2 广义表的存储结构

广义表的存储结构

6.3 广义表的操作算法

  • 直接递归法 - 原子直接操作,子表循环成原子进行操作
  • 减治法 - 先处理第一个元素(原子:直接操作 oror 子表:递归操作),最后递归操作剩余的元素

6.3.3 广义表的其他操作算法

复制广义表、计算广义表的长度、计算广义表的深度、释放广义表的存储空间

七、树和二叉树

7.1 树的概念和性质

7.1.1 树的定义

7.1.2 树的基本术语

  1. 结点的度和树的度
    • 结点的度:每一个结点孩子结点的数量
    • 树的度:一棵树中结点度数的最大值
  2. 孩子、双亲、兄弟结点
  3. 路径和路径长度
  4. 子孙结点和祖先结点
  5. 结点的层次和树的高度
  6. 有序树和无序树
    • 有序树:子集不可以随意交换
    • 无序树:子集可以随意交换
  7. 森林
    • 多棵树

7.1.3 树的基本性质

7.2 二叉树的概念和性质

7.2.1 二叉树的定义

7.2.2 二叉树的基本性质

  1. 根是第一层。第 ii 层最多有 2i12^{i - 1} 个结点

  2. 树中叶子结点的个数为 n0n_0,度数为2的结点的个数为 n2n_2。已知树的点数为 nn,边数为 mm,则 n=m+1n = m + 1。而 n=n0+n1+n2n=n_0+n_1+n_2m=n1+2n2m=n_1+2n_2,则 n0+n1+n2=n1+2n2+1n_0+n_1+n_2 = n_1+2n_2 +1,则

    n0=n2+1n_0=n_2 + 1

  3. 满二叉树:每一层都是满结点

    满二叉树

  4. 完全二叉树:对于一个 kk 层的二叉树,1k11\to k-1 都是满的,第 kk 层从左到右连接叶子结点

    完全二叉树

    结点数固定,则完全二叉树的形状唯一

    完全二叉树的性质

    ii 为奇数,且 i1i\neq1,则左兄弟就是 i1i-1

    ii 为偶数,则右兄弟就是 i+1i+1

7.3 二叉树的存储结构

7.3.1 二叉树的顺序存储结构

  • 对于一般的二叉树,将其转化为完全二叉树进行存储即可
  • 插入删除操作都不方便

7.3.2 二叉树的链式存储结构

7.4 二叉树的遍历

7.4.1 二叉树遍历的概念

7.4.2 二叉树遍历算法

  1. 先中后遍历
    1. 递归遍历
    2. 栈式遍历
  2. 层序遍历

7.4.3 二叉树的构造和析构

  1. 由含空指针标记的单个遍历序列构造二叉树

    可以从遍历的逻辑进行逆推。在遍历到空指针的时候输出一个编制符号,然后在构造的时候按照遍历序列进行递归构造即可,如图

    先序序列进行构造:按照遍历的思路来,对于先序序列而言,第一个元素一定是根元素,因此首先根据“当前局面”的第一个元素创建根结点,接着递归创建左子树和右子树即可。注意传递的序列起始下标是引用类型的变量

    示例

    示例

    中序序列进行构造:

    不可以,因为不能确定根节点以及左子树和右子树的部分

    后序序列进行构造:与上述先序序列进行构建的逻辑一致,只不过有一个小 trick,即我们从后序序列的最后一个元素开始创建,那么得到的第一个元素就是根结点的值,然后首先递归创建右子树,再递归创建左子树即可。同样需要注意的是传递参数时,序列起始下标是引用类型的变量

    与先序序列构造逻辑相同,只是递归的顺序需要调整一下

  2. 由两个遍历序列构造二叉树

    • 先+中:构造逻辑与上述带标记的序列构造逻辑几乎一致,只不过区别在于如何进行递归中参数的传递。传递的参数除了先序和中序的字符串,还有当前局面先序序列的起始下标与当前局面中序序列的起始下标,以及以当前序列进行构造时子树的结点个数。很容易就可以找到当前序列的根结点,接着就是利用很简单的下标关系得到上述的三个参数的过程,最后将新得到的三个参数传递给递归函数进行递归构建左右子树即可,当前的根结点是 pre[ipre]
    • 后+中:逻辑与上述一致,只不过当前的根结点是 post[ipost+n-1]
  3. 由顺序结构构造链式结构

  4. 拷贝构造

  5. 析构

7.5 二叉树的其他操作算法

  1. 计算二叉树的结点数
    • 有返回值的递归
    • 无返回值的递归
  2. 计算二叉树的高度
    • 有返回值的递归
    • 无返回值的递归
  3. 根据关键值查找结点
  4. 查找结点的父结点

7.6 线索二叉树

7.6.1 线索二叉树的概念

将空指针域用前驱 or 后继结点的地址进行覆盖

7.6.2 线索二叉树的存储结构

依旧是链式存储,只不过增加了结点域中的指针类型,分为链接类型Link与线索类型Thread

7.6.3 线索二叉树的操作算法

以中序线索化的二叉树为例,涉及到以下几种算法:

  1. 线索化:设置一个全局变量 pre,为了简化思维,我们可以将一个中序遍历的过程想象成一个线性结构。前驱为 pre,当前为 p

    • p 的左子树为空,则 p 的前驱为 pre
    • pre 的右子树为空,则 pre 的后继为 p
  2. 求后继结点和前驱结点

  3. 遍历

  4. 求父结点

    • 首先,若已知当前是左子树,则父结点一定是当前右孩子的中序前驱线索;若已知当前是右子树,则父结点一定是当前左孩子的中序前驱线索
    • 但是在未知当前结点的位置(未知左右子树)时,同时搜索两边的父结点,然后根据试探出来的父结点,特判父结点的子结点是否是当前结点即可

7.7 树的存储结构与算法

7.7.1 树的存储结构

  1. 多叉链表表示法:将每一个结点的子结点都预设置为一个定值(树的最大度数):浪费空间

  2. 孩子链表表示法:自顶向下存储边的信息

    1
    2
    3
    4
    5
    6
    7
    8
    9
    template<class T>
    struct CTBox {
    T data;
    CTNode* firstchild;
    };
    struct CTNode {
    int child;
    CTNode* next;
    };

    孩子链表表示法

  3. 双亲表示法:自下向上存储边的信息

    双亲表示法

  4. 孩子兄弟表示法:左结点存储孩子,右结点存储兄弟

7.7.2 树的操作算法

构造、计算树的高度、计算树中所有结点的度

7.8 哈夫曼树与哈夫曼编码

7.8.1 哈夫曼树的定义

树的路径长度:叶子结点到根结点的路径之和

  1. 树的带权路径长度 WPLWPL :叶子结点到根结点的路径之和 ×\times 叶子结点的权重,整体之和
  2. WPLWPL 最小的树就叫做哈夫曼树:对于一个结点序列n,每次选择其中的两个权值最小的两个结点进行合并,在进行了n-1次以后,得到的二叉树就是哈夫曼树
  3. 哈夫曼编码:
    • 编码:利用二叉树进行前缀编码 - 避免解码时的二义性
    • 解码:根据编码的二叉 trie 树,进行解码

7.8.2 操作算法

构造 Huffman 树、编码、解码

八、图

8.1 图的基本概念

8.1.1 图的定义

Graph=(V,E)Graph = (V,E)

完全无向图:edge=n(n1)/2edge=n(n-1)/2

完全有向图:edge=n(n1)edge=n(n-1)

8.1.2 图的基本术语

  • 带权图称为网

  • 无向图:连通图和连通分量

    • 连通图:每一个顶点之间都有路径可达
    • 连通分量:极大连通子图
  • 有向图:强连通图和强连通分量

    • 强连通图:每一个顶点之间都有路径可达
  • 强连通分量:极大强连通子图

8.2 图的存储结构

教材中的点编号统一从 00 开始

8.2.1 邻接矩阵

无向图的度:第 ii 行(列)的非标记数的个数

有向图的度:入度为第 ii 行的非标记数的个数;出度为第 ii 列的非标记数的个数

类定义:

邻接矩阵 - 1

邻接矩阵 - 2

8.2.2 邻接表

存储出边表称为邻接表,存储入编表称为逆邻接表

类定义:

邻接表

8.3 图的遍历

8.3.1 图遍历的概念

每个结点只能访问一次,故需要开启标记数组用来记录是否访问的情况

8.3.2 深度优先搜索

深度优先搜索

邻接矩阵:

  • 时间复杂度:O(n2)O(n^2)

  • 针对邻接矩阵的一个无向连通图的搜索代码示例

    邻接矩阵的一个无向连通图的搜索代码示例

邻接表:

  • 时间复杂度:O(n+e)O(n+e)

  • 针对邻接表的一个无向连通图的搜索代码示例

    1
    2
    3
    4
    5
    6
    template<class T>
    void ALGraph::DFS(int v, bool* visited) {
    cout << vexs[v];
    visited[v] = true;
    // 遍历所有的边
    }

8.3.3 广度优先搜索

通过队列实现、时间复杂度与上述 DFS 算法类似

8.3.4 图遍历算法的应用

  1. (u,v) 的所有简单路径:dfs + 回溯法的简单应用

  2. 染色法求二部图:bfs 的简单应用。当然 dfs 也是可以的,只需要在染色之后判断是否有相同颜色的邻接点即可

8.4 最小生成树

8.4.1 最小生成树的概念及其性质

Minimum Spanning Tree(MST)Minimum\ Spanning\ Tree(MST)

证明:

最小生成树性质证明 - 图例

对于上述的一个割,选择其中权值最小的交叉边。从而对于所有的状态,每次选择最小交叉边即可。

8.4.2 Prim算法

算法标签:greedygreedy

  • 构造 n1n-1 个割的状态

  • 起始状态为:顶点集合 UU11 个顶点,顶点集合 VUV-Un1n-1 个顶点

  • 状态转移为:

    • 选完最小交叉边之后,将这条边在集合 VUV-U 中的顶点加入到最小生成树集合 UU
    • 更新最小交叉边数组 miniedges[ ]miniedges[\ ]
  • 时间复杂度:O(n2)O(n^2)

8.4.3 Kruskal算法

算法流标签:greedy,dsugreedy,dsu

  • 初始化 nn 个顶点作为 nn 个连通分量
  • 按照边的权值升序进行选择
    • 如果选出的边的两个顶点不在同一个集合,则加入最小生成树
    • 如果选出的边的两个顶点在同一个集合,则不选择(如果选了就会使得生成树形成回路)
  • 时间复杂度:O(eloge)O(e\log e)

8.5 最短路径

8.5.1 最短路径的概念

单源最短路

DijkstraDijkstra 算法无法求解含负边权的单源最短路

BellmanFordBellman-Ford 算法支持负边权的单源最短路求解

SpfaSpfa 算法同样支持负边权的单元最短路,属于 BellmanFordBellman-Ford 算法的优化

多源最短路

FloydFloyd 适用于求解含负边权的多源最短路

8.5.2 单源最短路径 - DijkstraDijkstra 算法

算法标签:greedygreedy dpdp

其实就是 PrimPrim 的另一种应用

  • PrimPrim 是只存储交叉边的最小值
  • DijkstraDijkstra 是存储交叉边的最小值 ++ 这条边在集合 S 中的点已经记录的值
  1. 朴素版:

    • 邻接矩阵

    • 定义 d[i]d[i] 表示从起点到当前i号点的最短路径的长度

    • 将顶点分为两个集合,SSVSV-S,其中 SS 表示已经更新了最短路径长度的顶点集合

    • 迭代更新过程:依次更新每一个结点,对于当前结点 viv_i,在集合 SS 中的所有结点中,选择其中到当前结点路径最短的顶点 vjv_j,则 d[i]=d[j]+edges[j][i]

    • 时间复杂度:O(n2)O(n^2)

  2. 堆优化:

    • 邻接表

    • 时间复杂度:O(eloge)O(e \log e)

8.5.3 多源最短路径 - FloydFloyd 算法

算法标签:dpdp

多阶段决策共 nn 个阶段,dp[i][j] 表示每一个阶段 kk,从 iijj 的选择前 kk 个顶点后的最短路径的长度

对于当前阶段 kk,我们利用阶段 k1k-1 的状态进行转移更新,其实就是对于新增加的顶点 vkv_k 是否选择的过程

  • 选择 vkv_k,则 dp[i][j] = dp[i][k] + dp[k][j]
  • 不选 vkv_k,则 dp[i][j] 就是 k1k-1 状态下的 dp[i][j]

8.6 AOV网与拓扑排序

8.6.1 有向无环图与AOV网的概念

  • 有向无环图:DAGDAG
  • AOV网: (activity on vertex network)(activity\ on\ vertex\ network)
  • 应用场景:在时间先后上有约束关系的工程管理问题

8.6.2 拓扑排序

  • 定义:顶点线性化
  • 应用:判环、判断一个图是否可以进行动态规划
  • 算法设计:对于有向图,从所有的入度为 0 的点开始删点删边,到最后判断有多少点被删除即可
  • 算法实现:可以采用 dfs 进行缩点删边,也可以采用 bfs 进行缩点删边
  • 时间复杂度:O(n+e)O(n+e)

九、查找

9.1 静态查找表

定义:只支持查询和修改,不支持删除与插入

9.1.1 顺序查找

9.1.2 折半查找

9.1.3 分块查找

结合顺序查找与分块查找的一种方法

分块查找

  • 索引表可以折半或者顺序查找
  • 块内部只能顺序查找

9.2 动态查找表

9.2.1 二叉排序树

定义:根结点比左子树所有结点的值都大,比右子树所有结点的值都小。关键字唯一

操作:查找、插入、删除

判定:想要判定一棵二叉树是否为二叉排序树,只需要判断中序遍历的结果是不是递增的即可,可以采取中序遍历序列比对的方法,也可以在递归遍历二叉树的过程中通过记录前驱结点的值直接进行比较判断。时间复杂度:O(n)O(n)

9.2.2 平衡二叉树

定义:平衡因子为左子树的高度 - 右子树的高度,平衡二叉树的平衡因子绝对值 <= 1

构建:当插入结点进行构建时出现了有结点平衡因子的绝对值超过了1,则进行“旋转”调整,旋转共分为4种

旋转 - LL、LR

旋转 - LR

旋转 - RL

尝试模拟一遍下列序列的构造过程就可以理解了

例题

9.3 Hash 查找

定义:装填因子 α=nm\alpha=\frac{n}{m},其中 nn 表示待填入表中的结点数,mm 表示哈希表的空间大小

哈希函数应该满足以下两点:第一、映射出来的地址不会越界;第二、映射出来的地址是唯一的

9.3.1 构造表

常用的哈希函数

  1. 直接地址法 - 线性函数一对一映射

    优点。计算简单且不可能产生冲突

    缺点。对于空间的要求极高,如果数据过于离散,则会造成很大的空间浪费

  2. 数字分析法 - 按照数位中的数值分布情况进行哈希

    缺点。需要预先知道数据的数字分布情况

  3. 平方取中法 - 对于 10m10^m 的哈希空间,可以将数字平方后取中间 mm 位进行哈希存储

  4. 折叠法

    • 移位法:将一个数字按照数位拆分为几个部分,然后将几个部分的数值累加出一个数即可,高位抹去不用

    • 间隔法:与移位法几乎一致,只不过将其中的部分意义间隔的进行数值反转,最后累计即可,高位抹去不用

  5. 除留余数法 - 按照数值 mod p\text{mod}\ p 后的数值进行哈希,假设哈希表空间大小为 mm ,则 pp 一般取 m\le m 的质数

处理冲突

  1. 开放定址法 - 探测开放地址,一般有三种
    • 连续序列进行线性探测
    • 左右倍增序列进行探测
    • 伪随机序列进行探测
    • 双 hash 探测法
  2. 拉链法
    • 定义:将产生 hash 冲突的元素放入同一个子集,通过单链表进行存储
    • 优点:没有堆积现象,从而减少了很多不必要的比价,提升比较效率;适合一开始不知道表长的情况;除结点更加容易。

9.3.2 查找表

按照构造相同的逻辑进行查找即可

十、排序

10.1 排序的基本概念

关键字:

  • 主关键字:每一个待排序的该关键字是独一无二的
  • 次关键字:每一个待排序的该关键字可能是重复的

稳定性:

  • 场景:只针对次关键字的情况
  • 稳定:按照次关键字排序后,原来相同关键字的顺序不变
  • 不稳定:按照次关键字排序后,原来相同关键字的顺序可能会改变

内外排序:

  • 内排序:数据全部存放在内存
  • 外排序:数据量过大时,待排序的数据在内存与外存之间不断转换

10.2 冒泡排序

稳定的。基于交换的思路进行

10.3 选择排序

  • 选择第 1 小的数放在第一个位置,…,选择第 i 小的数放在第 i 个位置

  • 共选择 n-1 次

10.4 插入排序

稳定的

  • 直接插入排序:依次向前缀已经排好序的序列中进行插入 - O(n2)O(n^2)
  • 折半插入排序:同上,只是选择插入位置的使用二分 - O(nlogn)O(n\log n)
  • 递归插入排序:排序 [1,i] 等价于先排好 [1,i-1],然后插入当前 num[i] 即可

10.5 希尔排序

不稳定

基于插入直接排序的优点:

  1. 当序列基本有序时,效率很高
  2. 当待排序数很少时,效率很高

于是希尔(Shell)就得出来以下的希尔排序算法:

  1. 将序列划分一定次数,从 d<n 到 1
  2. 每次划分都对组内的元素进行直接插入排序
  3. 最后分为 1 组时,直接排序一趟以后就可以得到 sortrd sequence

10.6 快速排序

不稳定

分治法三步骤:divide、conquer and combine

每次选择一个 pivot 进行 partition,递归两个 partition

1
2
3
4
5
6
7
8
9
10
11
12
void Sort(int l, int r) {
if (l >= r) return;

int i = l - 1, j = r + 1, x = a[l + r >> 1];
while (i < j) {
while (a[++i] < x);
while (a[--j] > x);
if (i < j) swap(a[i], a[j]);
}

Sort(l, j), Sort(j + 1, r);
}

10.7 堆排序

不稳定

堆与堆排序的定义:首先我们得知道什么是堆结构。堆是具有下面性质(对于任意的 1in/21\le i \le n/2 )的完全二叉树

  • kik2i,kik2i+1k_i \le k_{2i},k_i \le k_{2i+1} 叫做 小顶堆
  • kik2i,kik2i+1k_i \ge k_{2i},k_i \ge k_{2i+1} 叫做 大顶堆
  • 因此一个堆结构可以采用线性的单元进行存储与维护。而堆排序利用堆顶是最值这一性质,通过不断的取堆顶,调整堆的方式获得最终的排好序的序列

建立初始堆:由于完全二叉树中,每一个叶子结点都已经是堆结构,因此直接从第一个非叶子结点开始建堆即可。对每一个元素与左孩子、 右孩子进行比较

  • 如果当前结点的值比左右孩子都大,那么无需修改,当前位置就是堆顶
  • 如果当前结点的值比左孩子或者右孩子中的最大值小,则将最大的孩子作为堆顶,并将当前值不断的“下沉”即可

交换堆顶与记录位置后重新建堆:交换记录值获取当前堆中最值以后,需要将除了已记录的值的结点以外的所有结点重新调整为堆结构

  • 调整为堆结构的过程与上述初始建堆的过程完全一致,只是结点数每次 -1

时间复杂度:O(nlogn)O(n \log n)

10.8 归并排序

稳定的

递归:同样采用分治法,我们按照分治法的三个步骤进行讨论

  • divide:将当前序列划分为左右两部分
  • conquer:递归处理上述划分出来的两部分
  • combine:归并上述递归完的两部分

非递归:就是模拟上述递归的过程,可以拆分为三步

  • 归并
  • 按照指定的长度处理整个序列
  • 划分局部排序的长度

时间复杂度:O(nlogn)T(n)=2T(n2)+O(n)O(n \log n)\leftarrow T(n)=2T(\frac{n}{2}) + O(n)

]]>
@@ -1136,11 +1136,11 @@ - DigitalLogicCircuit - - /GPA/3rd-term/DigitalLogicCircuit/ + LinearAlgebra + + /GPA/3rd-term/LinearAlgebra/ - 数字逻辑电路

1 概论

1.1 数字信号 | 数字电路

1.1.1 数字技术的发展及其应用

  1. 电流控制器件:电子管、晶体管(二极管、三极管)、半导体集成电路

  2. EDA (Electronic Design Automation) 技术(硬件设计软件化):设计:EWB or Verilog、仿真、下载、验证结果

1.1.2 数字集成电路的分类及特点

  1. 数字集成电路的分类

    1. 从结构特点及其对输入信号的响应规则角度:组合逻辑电路、时序逻辑电路

    2. 从电路的形式角度:集成电路、分立电路

    3. 从器件的角度:TTL 电路、CMOS 电路

    4. 从集成度(每一个芯片所包含的门个数)的角度:小规模、中规模、大规模、超大规模、甚大规模

  2. 数字集成电路的特点

    1. 稳定性高:抗干扰能力强
    2. 易于设计:对 0 和 1 表示的信号进行逻辑运算和处理
    3. 便于集成:体积小、通用性好、成本低
    4. 可编程性:可实现硬件设计软件化
    5. 高速度、低功耗
    6. 便于存储、传输、处理
  3. 数字电路的 分析设计 与测试

    1. 分析方法
      • 目标:确定输入与输出
      • 工具:逻辑代数
      • 方法:真值表、功能表、逻辑表达式和波形图
    2. 设计方法
      • 从功能要求出发,选择合适的逻辑器件进行设计
      • 设计方式:传统的设计方法 or 基于 EDA 的软件设计方法
    3. 测试技术

1.1.3 模拟信号与数字信号

  1. 模拟信号:时间和数值均 连续 变化的信号

  2. 数字信号:时间和数值均 离散 变化的信号

  3. 模拟量的数字表示:模数转换,即将连续的模拟信号经过 采样与编码 转化为数字信号

    • 首先对时间离散
    • 然后对幅值离散
    • 最后对得到的数字量进行编码

1.1.4 数字信号的描述方法

  1. 二值数字逻辑和逻辑电平

    二值数字逻辑:0 和 1 两种状态(定量)

    逻辑电平:高电压和低电压(定性)

    正逻辑关系表(负逻辑相反):

    电压(V)二值逻辑电平
    3.5~51H(高电压)
    0~1.50L(低电压)
  2. 数字波形

  3. 实际数字信号波形

  4. 波形图、时序图或定时图

1.2 数制

(N)r=i=Ki ri(N)_r =\sum_{i =-\infty}^\infty K_i\ r^i

1.2.1 十进制

1.2.2 二进制

优点:易于表达;二进制数字电路 逻辑简单,所用元件少;基本运算 规则简单,运算操作方便

波形表示:应用比如“计数器”

数据传输:应用比如“串行传输”

1.2.3 十-二进制之间的转换

十进制小数转化为二进制:将小数部位不断 ×2,取整数,直到没有小数部分为止

1.2.4 十六进制和八进制

二进制转十六进制:从右往左每四位换算成十六进制

二进制转八进制:从右往左每三位换算成八进制

1.3 二进制数的算术运算

1.3.1 无符号二进制数的运算

1.3.2 有符号二进制数的运算

定义:其实就是多了一个符号位,且不可以省略。其中 0 表示正数,1 表示负数:

(+11)D=(01011)B(11)D=(11011)B\begin{aligned}(+11)_D =(01011)_B \\(-11)_D =(11011)_B \\\end{aligned}

补码、反码和原码:

  • 对于正数,补码反码原码全部一样

  • 对于负数,反码为:符号位不动,原码按位取反;补码为:反码最低位+1 即可

加法:与十进制竖式计算类似

减法:与十进制竖式计算类似

溢出:是因为数值位不够了,解决方法是进行 位扩展

溢出的判别:

  • 两个正数的求和,得到的补码的最高位如果为 1,则溢出

  • 两个负数的求和,得到的补码的最高位如果为 0,则溢出

1.4 二进制代码

1.4.1 二/十进制码

其实就是在表示 0-15 的十六个二进制里面,按照不同的规则选取 10 个二进制数来进行转换

  1. 有权码 - 最接近逻辑的:8421BCD 码
  2. 无权码

1.4.2 格雷码

1.4.3 ASCII 码

1.5 二值逻辑变量与基本逻辑运算

1.5.1 常见逻辑符号示例

运算类型逻辑符号逻辑表达式
image-20231008095031818Y=ABY=AB
image-20231008095047759Y=A+BY=A+B
image-20231008094808523Y=AY=\overline{A}
与非image-20231008095111128Y=ABY=\overline{AB}
或非image-20231008095809755Y=A+BY=\overline{A+B}
异或image-20231008095902796Y=AB+AB=ABY=\overline A B+A \overline B=A \oplus B
同或image-20231008095922663Y=AB+AˉBˉ=ABY = AB + \bar A \bar B = A\odot B
与或非image-20231008095947154Y=AB+CDY=\overline{AB+CD}

1.5.2 使用逻辑函数表示实际问题

实际问题图片示例变量表示列真值表逻辑函数
image-20231008102102546image-20231008102037276image-20231008101253710image-20231008101303842image-20231008101318582

1.6 逻辑函数及其表示方法

描述输入逻辑变量和输出逻辑变量之间的因果关系,称为逻辑函数

1.6.1 逻辑函数的几种表示方法

方法示例
真值表image-20231008110656451
逻辑函数表达式image-20231008110713908
逻辑图image-20240116131615935
波形图image-20231008111013474

1.6.2 逻辑函数表示方法之间的转换

真值表到逻辑图的转换

  • 查看真值表

    image-20231008112605100
  • 根据真值表写出逻辑表达式

    image-20231008112628066
  • 化简(上式不用化简)

  • 绘制逻辑图

    image-20231008112708161

逻辑图到真值表的转换

  • 根据逻辑图逐级写出表达式

    image-20231008112800422
  • 化简

  • 代入所有输入变量求真值表

    image-20231008112839220

2 逻辑代数 | 硬件描述语言基础

2.1 基本定律和规则

2.1.1 基本定律和恒等式

image-20231008114849627

image-20231008114943429

2.1.2 基本规则

  1. 代入规则 - 类似于换元

    image-20231008115435355

  2. 反演规则(获得反函数 Y\overline Y

    觉得烦可以直接进行取反运算,简单明了不会错

    • 对于任意一个逻辑表达式 L,与门 & 或门取反,变量取反,0 & 1 取反
    • 保持原来的运算优先顺序(即如果在原函数表达式中,AB 之间先运算,再和其他变量进行运算,那么非函数的表达式中,仍然是 AB 之间先运算)
    • 对于反变量以外的非号应保留不变

    image-20231008115328932

    image-20231008115337125

  3. 对偶规则(获得对偶式 LL'

    • 对于任何逻辑函数式:与门、或门取反,0、1 取反

2.2 逻辑函数表达式的形式

2.2.1 基本形式

  1. 与或表达式:若干个与项相或

    image-20231013094928938

  2. 或与表达式:若干个或项相与

    image-20231013094938441

2.2.2 最小项与最小项表达式

  1. 最小项的定义和性质:n 个变量的最小项一共有 2n2^n

  2. 最小项表达式:所有的最小项相或

    image-20231013100105922

2.2.3 最大项与最大项表达式

  1. 最大项的定义和性质:n 个变量的最大项一共有 2n2^n

  2. 最大项表达式:所有的最大项相与

    image-20240116134303065

2.2.4 最大项和最小项的关系

mi=Mim_i =\overline {M_i}

2.3 逻辑函数的代数化简法

为什么要学化简?因为化简之后可以减少门的使用,从而增强电路可靠性、降低成本

2.3.1 逻辑函数的最简形式

最简与或表达式:包含的与项数最少,且每个与项中变量数最少的与或表达式

2.3.2 逻辑函数的代数化简法

  1. 逻辑函数的 化简

    方法逻辑函数证明
    并项法A+A=1A+\overline A = 1显然
    吸收法A+AB=AA+AB = A提取公因子
    消去法A+AB=A+BA+\overline A B = A+B摩根定律使用两次
    配项法A=A(B+B)A = A(B + \overline B)显然
  2. 逻辑函数形式的 变换

    使用场景:通常在一片集成电路芯片中只有一种门电路,为了减少门电路的种类,需要对逻辑函数表达式进行变换

    变换方法:常常使用两次取反的套路进行变换

    image-20231013112834593

2.4 逻辑函数的卡诺图化简法

2.4.1 用卡诺图表示逻辑函数

首先写出逻辑函数的表达式并且转化为最小项表达式,最后将最小项填入相应的矩阵中即可

2.4.2 用卡诺图化简逻辑函数

尽可能使得圈出来的 2k2^k 圈中包含的数尽可能的多,即让 kk 尽可能的大。注意:圈中的数全部都得是最小项的数

2.5 Verilog HDL 基础

为了从软件代码的角度描述电路,从下面三个方面介绍如何用 Verilog 描述数字逻辑电路。

2.5.1 门级描述

门级元件中,第一个位置是输出变量,之后的都是输入变量,可解释为:多输入门

门级元件元件符号
and
or
not
与非nand
或非nor
异或xor
同或xnor

2.5.2 数据流描述

简单的概括就是使用相关的位运算进行表述,因为电路逻辑本就是二元逻辑,因此位运算就刚好匹配。在使用数据流进行电路描述时,采用的语句都是连续赋值语句,由 assign 关键词开始,多条 assign 语句是 并行 运行的

需要注意的是,在连续赋值语句中,被赋值的变量一定是 wire 的 线网 类型的变量,示例如下

1
2
// 其中 Y 为 wire 类型的变量
assign Y = (~S & D0) | (S & D1)

2.5.3 行为描述

简单的概括就是使用底层语言进行编程,类似于最开始的 C 语言。使用行为描述语句进行描述时,使用 always 关键字开始变量赋值逻辑,多条 always 语句是 串行 运行的

需要注意的是,在行为描述语句中,被赋值的变量一定是 reg 等 寄存器 类型的变量,这与上述数据流描述的方式不同,示例如下

1
2
3
4
// 其中 Y 为 reg 类型的变量
always@(*)// * 为敏感变量,对于组合电路而言,所有的输入都是敏感变量
if (S) Y = D1;
else Y = D0;

3 逻辑门电路

3.1 简介

MOS 管含有 NMOS 管和 PMOS 管,NMOS 管与 PMOS 管的组合称为互补 MOS,或称为 CMOS 电路。

3.2 基本 CMOS 逻辑门电路

附上启蒙的博客:MOS 管及简单 CMOS 逻辑门电路原理图解析!

器件电路
开关image-20231201090156879
反相器(非门)image-20231201090250860
与非门image-20231201090233084
或非门image-20231201090319423
传输门(开关)image-20231222104533397
与门image-20240117003028351
或门image-20240117003058029

应用示例

解读的逻辑其实很简单,在理解之前,应该首先观看上面给出的连接中的 MOS 电路的简化版,从而理解电路的正确结构!即,每一个 MOS 管都理解为一个开关,何时闭合与断开完全取决于相应的 MOS 管的种类与电平,如果是 NMOS 管,即箭头指向左边的,为高电平导通,PMOS 管则相反,只需要知道此电路基本逻辑,那么接下来的分析结果就是水到渠成的事。

需要知道一个理念就是,两个电路如果是并联的存在,那么逻辑表达式就是或,简称为 并联相或;对应的,两个电路如果是串联的存在,那么逻辑表达式就是与,简称为 串联相与。最后需要补充一点的就是关于取反的辨识,我们知道一个反相器 MOS 管的逻辑是非常简单的,就是一个 NMOS 管和一个 PMOS 管的组合,那么只需要在分析多个线路是串联还是并联的关系之后,最后经过一个反相器就是一个 取反 逻辑。

电路逻辑表达式功能描述
image-20231201090643503image-20231201090727291异或门
image-20240117103214449L=(BC+D)AL=\overline{(BC+D)A}
image-20240117103238963L=(A+B)X=(A+B)AB=ABL=\overline{(A+B)X}=\overline{(A+B)\overline{AB}}=A\odot B同或门
image-20231201091736402image-20231201091750341异或门
image-20231201091831389image-202312010918589292 选 1 数据选择器

4 组合逻辑电路

4.1 分析策略

组合逻辑电路只取决于实时输入从而给出相应的输出,与之前的运行结果无关。没有反馈和记忆单元。分析流程如下:

  1. 由逻辑图得到 逻辑表达式
  2. 化简和变换
  3. 真值表
  4. 根据真值表(或者波形图)分析电路功能

4.2 设计方法

4.2.1 设计过程

  1. 明确逻辑含义:确定输入输出并定义逻辑状态的含义
  2. 列出真值表:根据逻辑描述写出真值表
  3. 写出逻辑表达式:由真值表写出逻辑表达式,真值取原、假值取反
  4. 化简逻辑表达式:代数化简法 or 卡诺图化简法
  5. 画出逻辑图:使用相应的门级元件进行组合连接

4.2.2 优化实现

电路类型优化策略电路图优化结果
单输出电路统一元件类型image-20231117112320588见左图文字
多输出电路共享相同逻辑项image-20231117112426358见左图文字
多级逻辑电路(限定入数)提取公因项image-20231117112559081见左图文字
多级逻辑电路(限定入数)提取公因项image-20231117112618856见左图文字

4.3 竞争与冒险

为什么会产生?门级元件的延时效应。

如何消去呢?有三种方法:

  1. 消除互补变量。

  2. 增加乘积项,避免互补项相加。

  3. 输出端并联电容器。如下图:

输出端并联电容器

4.4 典型电路示例

举几个典型的组合逻辑电路。

4.4.1 编码器

普通编码器:只允许有一个输入,从而进行编码,一旦出现多输入就会发生错误。

优先编码器:无论多少输入,都会按照一开始设定的优先级进行最高等级的那一个信号位的编码。

名称型号逻辑符号功能分析逻辑图
4-2 优先编码器74LS002 片 7400(4 个 2 输入与非门)实现需要将 4-2 优先编码器的两个逻辑函数转化为与非式,从而进行电路逻辑的搭建。化简后发现需要 7 个 2 输入与非门,故需要 2 片 7400 才能实现 4-2 线优先编码器
8-3 优先编码器CD4532image-20231208104642447除了 8 个输入端与 3 个输出端,还有 EI、EO 与 GS 端。其中 GS 是用来标明当前电路是否处于工作状态的,即如果没有输入端为有效信号,GS 就是低电平,反之则是高电平。而 EI 与 EO 是为了电路扩展而诞生的,当 EI 为高电平且没有任何输入的情况下,EO 也是 1,此时的 4532 就相当于一根导线,从而可以进行片子的扩展由于有现成的集成电路板,故就是逻辑符号
16-4 优先编码器CD45322 片 4532 实现首先确保 EI 始终为高电平。输出后三位 就是两个 4532 片子的 3 输出分别或的结果,最高位的输出 是高位片的 GS 端的结果image-20240117120736794
32-5 优先编码器74LS00+CD45321 片 7400+4 片 4532 实现首先确保 EI 始终为高电平。输出后三位 就是四个 4532 片子的 3 输出分别或的结果,最高位的两个输出 取决于 4 个片子 GS 端 4-2 优先编码的结果。image-20240117121434992

4.4.2 译码器/数据分配器

名称型号逻辑符号功能分析逻辑图
2-4 译码器74X139image-20231208101820876使能端有效时。按照对应的输出给出相应输出的低电平信号由于有现成的集成电路板,故就是逻辑符号
3-8 译码器74X138image-20231208101658175使能端有效时。按照对应的输出给出相应输出的低电平信号由于有现成的集成电路板,故就是逻辑符号
4-16 译码器74X138 或 74X1392 片 74X138 或 5 片 74X139使能端有效时。输入的前三位 分别接入两片 3-8 译码器的输入端,输入的最后一位 接入两片 3-8 译码器的高电平使能端即可;如果用 2-4 译码器来实现,输入的前两位 分别接入四片 2-4 译码器的输入端,输入的后两位 通过一个 2-4 译码器的四个输入分别接入 4 片 2-4 译码器的低电平使能端即可image-20240117125311036
5-32 译码器74X139+74X1381 片 74X139+4 片 74X138使能端有效时。输入的前三位 分别接入四片 3-8 译码器的输入端,输入的后两位 通过 2-4 译码的 4 个结果分别接入四片 3-8 译码器的低电平使能端,从而决定是哪一个 3-8 译码器在工作image-20231208104231966

使用译码器实现逻辑函数

我们知道译码器的每一个输出代表一个最小项,那么对于一个 xx 变量的逻辑函数,可以通过以下步骤用 x2xx-2^x 译码器实现任意 xx 变量的逻辑函数

  1. 将逻辑函数转化为最小项表达式(大量使用摩根定律)
  2. 转化为译码器的输出(写成 mi\sum m_i 的形式)
  3. 在译码器的输出端加一个多输入与非门即可(对结果进行与非)

image-20231208110328594

数据分配器

功能:相当于多输出的单刀多掷开关,是将公共数据线上的数据按需要送到不同的通道上去的逻辑电路。

image-20231208114700981

图一:示意图

image-20231208114749275

图二:功能仿真图

4.4.3 数据选择器

名称型号逻辑符号功能分析逻辑图
2 选 1image-20240117135219030通过控制端 SS 来选择 D0,D1D_0,D_1image-20240117135236405
4 选 1image-20240117135258894通过控制端 S0,S1S_0,S_1 来选择 D0,D1,D2,D3D_0,D_1,D_2,D_3image-20240117135326247
8 选 174HC151image-20240117135401170通过控制端 S0S2S_0-S_2 来选择 D0D7D_0-D_7由于有现成的集成电路板,故就是逻辑符号
16 选 12 片 74HC151通过控制端 S0S3S_0-S_3 来选择 D0D15D_0-D_{15}输入的前三位 连接三个控制端,输入的最后一位 连接两片 74151 的使能端,其实就是译码器的魔改版,让输出为相应的译码结果的高电平而已image-20240117135555070

使用数据选择器实现逻辑函数

  • 变量个数 << 数据选择端个数:变量直接对应数据选择端,多余的选择端置 0,最后相应的信号输入端进行赋 1 或赋 0 的操作即可
  • 变量个数 == 数据选择端个数:本质上就是将逻辑函数转化为最小项表达式,然后与标准与或式进行比对,已出现的最小项与 1,未出现的最小项与 0,从而配凑产生了数据选择器最开始的式子。落到逻辑图上就是,数据选择端接入函数变量,信号输入端接入相应的高低电平,出现的最小项就输入 1,未出现的就输入 0 即可
  • 变量个数 >> 数据选择端个数:
    • 刚好多 1 个:变量 or 变量的非接入信号输入端
    • 不止多 1 个:同样采用将变量作为数据信号输入端,此外可能需要借助相关的门电路辅助进行

4.4.4 数值比较器

4.4.5 算术运算电路

半加器:即不考虑低位进位的一位二进制加法器。其中 SS 为输出位,CC 为进位,没有考虑低位的进位

{S=ABC=AB\begin{cases}S &=& A \oplus B \\C &=& AB\end{cases}

全加器:即考虑低位进位的一位二进制加法器。其中 SS 为输出位,CiC_i 为低位的进位,CoC_o 为进位

{S=ABCiCo=AB+(AB)Ci=AB+(A+B)Ci\begin{cases}S &=& A \oplus B \oplus C_i \\C_o &=& AB + (A \oplus B)C_i = AB+(A+B)C_i\end{cases}

5 锁存器和触发器

本章介绍时序逻辑电路的存储单元,分别为锁存器和触发器。其中锁存器对电平敏感,触发器对边沿敏感

5.1 基本双稳态电路

双稳态电路

5.2 SR 锁存器

门级元件组成电路图功能分析
或非门实现image-20231222100822028高电平有效。全 0 不变,谁 1 谁有效,都 1 不确定状态
与非门实现image-20231222100904959低电平有效。全 1 不变,谁 0 谁有效,都 0 不确定状态
应用电路图功能分析
开关电路image-20231222101855246image-20231222102251343无论开关如何震动,输出始终正常
门控 SR 锁存器image-20231222103758704就是加了一个使能端 E,如果 E 为 1,则就是一个基本的 SR 锁存器,如果 E 为 0,则保持

5.3 D 锁存器

电路名称逻辑电路图功能分析
传输门控制的 D 锁存器image-20231222105139215E = 0, Q = 不变;E = 1, Q = D
逻辑门控制的 D 锁存器image-20240117191849592E = 0, Q = 不变;E = 1, Q = D

5.4 触发器

5.4.1 主从 D 触发器的电路结构和工作原理

主从 D 触发器

5.4.2 典型的主从 D 触发器集成电路

逻辑图

电路板

真值表

5.5 触发器的逻辑功能

本目需要掌握有关触发器的 特性表特性方程状态图 三者的单独书写以及相互转化的逻辑过程,还需要掌握不同的触发器之间的相互 替换实现

类型逻辑符号特性表特性方程状态图
D 触发器image-20240117190140089image-20240117190521349image-20240117190538232image-20240117190553444
JK 触发器image-20240117190400613image-20240117190647444image-20240117190703599image-20240117190716843
T 触发器image-20240117190415482image-20240117190800382image-20240117190818921image-20240117190838344
T’ 触发器image-20240117190915612T1T\equiv 1image-20240117190926990
SR 触发器image-20240117190440059image-20240117191017989image-20240117191040611image-20240117191057024

6 时序逻辑电路

本部分只需要掌握同步时序逻辑电路的分析即可,具体直接从例题出发。三道同步时序逻辑电路分析的例题见教材 P282 ~ P286,分别为:

  • 例一:可控二进制计数器
  • 例二:可控双向二进制计数器
  • 例三:脉冲分配器

6.1 同步时序逻辑电路的分析

下面介绍同步时序逻辑电路分析的五个步骤。在分析之前我们要知道我们的最终目标是什么,可以知道,我们分析电路的最终目标是想要量化的确定电路的物理实现的功能,至于如何设计,此处不予讨论。现在给定了一个同步时序逻辑电路的 逻辑电路图,接下来我们应该:

  1. 了解电路组成:同步 or 异步?穆尔型输出(与输入无关) or 米利型输出(与输入有关) or 都有?由什么触发器组成的?触发器类型是上升沿出发 or 下降沿触发?

  2. 列出三个方程:

    • 输出方程:电路的最终输出

    • 激励方程:触发器的输入

    • 状态方程:触发器的输出(将触发器的输入也就是激励方程代入触发器的特性方程即可)

  3. 写出转换表(分析功能用)

  4. 写出状态图(分析功能用)

  5. 写出时序图(分析功能用)默认状态的初值设置为 0

6.2 计数器

本节讲讲 N 位二进制计数器中,利用集成电路板 74LVC16174LVC161 实现的 4 位同步二进制递增计数器。进而引出利用该 4 位计数器 实现模 N 计数器 的分析与设计思路。同时补充 74LVC16274LVC162 实现的 4 位同步十进制递增计时器,进而引出相关的模 N 设计思路。下面分析 74LVC161 4 位同步二进制递增计数器集成板

74LVC161 集成板

74LVC161 逻辑功能表

注解:

  • CR\overline{CR}异步清零。即无视时钟脉冲信号,直接清零
  • PE\overline{PE}同步预置。即当有效始终脉冲沿到来时,实现 4 个预置位的输出,即 D3,D2,D1,D0D_3,D_2,D_1,D_0
  • CEP,CETCEP,CET:使能端。同时为高电平电路才能正常工作
  • TCTC:进位输出

小结:

  • 多个集成板进行计数

  • 考虑如何通信:低位进位作为高位使能?

  • 考虑如何清零:同步(异步)清零?同步(异步)置数?

  • 学会利用 74LVC161 的“反馈清零法”实现模 N 计数器

  • 学会利用 74LVC161 的”反馈置数法“实现模 N 计数器

  • 学会实现 74LVC162 十进制递增功能功能(同步清零同步置数

  • 学会利用 74LVC162 的同步清零的特性实现模 9 的九进制计数器功能

  • 学会利用 74LVC162 实现模 24 的二十四进制计数器功能:

    00-09 与 10-19 的计数:通过低位片的进位端,作为高位片的使能端即可

    20-23 与 23-00 的计数:通过将低位片的两个低位与高位片通过 4023 三输入与非门连接起来,当全为 1 时,就是计数到 23 的状态,此时对高低片进行同步清零即可

]]>
+ 线性代数

前言

本篇博客初稿完成于 2024.01.06,即大二上学期期末。参考《工程数学 线性代数》同济第七版。

由于初稿写作时博主的水平有限并且偏向于应试,写作水平多有不足并且内容有所缺失。现在在学习 ML 和 DL 甚至数字图像处理时,几乎满屏都是矩阵。汗颜的是,由于初学时几乎都是死记硬背以及应试,我一度怀疑自己没学过线代🤡,因此这篇博客会持续更新。

后续的更新会对内容进行整合与补充,包括二次型、线性空间和线性变换。同时偏向实际的应用,包括 AI 相关的矩阵计算和矩阵微积分、数字图像处理相关的变换策略。打星 ()(*) 的内容表示个人认为比较重要的部分。

为什么要学习线性代数?为什么会有线性代数?

线性代数是为了解决多元线性方程组而诞生的。(2024.01.06)

真的是这样吗?看到一篇博客是从程序语言角度进行理解的,还挺有意思:8分钟带你彻底弄懂《线性代数》。(2024.10.21)

1 行列式

1.1 基本概念

全排列。当一个序列含有 nn 个数并且序列中每一个位置只出现 [1,n][1,n] 一次,则称该序列为全排列。

逆序数。一个排列中每一个元素之前比其大的元素数量之和。

对换。顾名思义就是指排列中两个元素进行交换的操作。有以下两个结论:

  1. 一个排列中两个元素对换,排列逆序数的奇偶性改变。
  2. 奇排列对换成标准排列的对换次数为奇数,偶排列对换成标准排列的对换次数为偶数。(标准排列就是 nn 个数从小到大升序排列)

1.2 定义 *

我们以 n 阶行列式为例。nn 阶行列式的值为 n!n! 个项之和,每一项的组成方式为:每行选一个元素,每列选一个元素,这些元素之积,符号为

(1)N(row)+N(col)(-1)^{N(row)+N(col)}

1.3 性质

行列式的性质可以用来简化求值,下面简单介绍一下 5 个常见的行列式性质及其推论,相关的证明都可以用定义证出来,故省略。

  1. 行列式与其转置行列式相等。

  2. 对换行列式的两个行或者列,行列式的符号改变。

    • 推论。若行列式有两行或两列完全相同,则行列式的值为 0。
  3. 若行列式的某一行/列 ×k\times k,则行列式的值也 ×k\times k

    • 推论一。行列式的某一行/列中的公因子可以提到行列式之外。
    • 推论二。若行列式有两行/列成比例,则行列式的值为零。
  4. 若行列式的某一行/列都是两数之和,则可以拆分成两个行列式之和。

  5. 把行列式的某一行/列乘一个常数累加到另一行/列上,行列式的值不变。

关于行列式求值的技巧。在一开始对换行或者列的时候,尽可能保证左上角是数字 1,从而配凑出上三角进而直接用对角线之积求值。

1.4 按行/列展开

本目简单介绍一下行列式求值的另一个策略:按行/列展开。我们用 D 表示行列式 (Determinant)。用 MijM_{ij} 表示余子式,即行列式去掉 DijD_{ij} 元素所在的行和列后剩余元素拼接起来的行列式。用 AijA_{ij} 表示代数余子式,其中 Aij=(1)i+jMijA_{ij}=(-1)^{i+j}M_{ij}

若行列式的某一行/列只有一个元素不为零,则有:

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}

若行列式的某一行/列有多个元素不为零,则有:

D=i=1naxiAxiD =\sum_{i = 1}^n a_{xi}A_{xi}

将展开的那一行/列通过加法原理进行拆分,然后利用上述只有一个元素不为零时的一般情况进行证明即可。

已知 n 阶行列式 D,按第 xx 行展开后有 D=i=1naxiAxiD=\sum_{i=1}^n a_{xi}A_{xi},现在将 axia_{xi} 替换为 ayia_{yi}xyx\ne y,则 i=1nayiAxi=0\sum_{i=1}^n a_{yi}A_{xi}=0。道理很简单,现在求解的值其实也是一个行列式,并且这个行列式有两行/列的元素完全相等,那么显然的行列式的值就是 0。例如下面这道题:显然 (1) 的结果为 0,(2) 只需要配凑一下即可。

例题

特殊的行列式

下面补充几个特殊的行列式及其求值方法。

分块行列式

分块行列式

0 在左下或右上就是左上角与右下角行列式之积(D=D1D2D=D_1D_2),0 在左上或右下就是左下角与右上角行列式之积加上符号判定。

证明。分区域转换为上三角即可。

2n 阶行列式

2n 阶行列式

先行对换再列对换,通过分块行列式和数学归纳法,可得行列式的值是一个等比数列。

范德蒙德行列式 *

范德蒙德行列式

证明。首先从最后一行开始,依次减去前一行的 x1x_1 倍,凑出第一列一个元素不为零的情况,最后通过数学归纳法即可求解。项数为 Cn2C_n^2

2 矩阵

2.1 定义

相较于行列式是一个数,矩阵就是一个数表。下面补充几个常见的名词:

  • 方阵。若行列数相等均为 n,则可以称为方阵或 n 阶矩阵或 n 阶方阵。
  • 对角阵。即方阵的非主对角线元素均为 0。符号表示为 Λ=diag(λ1,λ2,,λn)\Lambda=diag(\lambda_1,\lambda_2,\cdots,\lambda_n)
  • 单位阵。即方阵的主对角线全 1,其余全 0。符号表示为 E=diag(1,1,,1)E=diag(1,1,\cdots,1)
  • 纯量矩阵。主对角线上元素全为 λ\lambda ,其余全 0。符号表示为 S=diag(λ,λ,,λ)S=diag(\lambda,\lambda,\cdots,\lambda)

2.2 运算

2.2.1 元素级运算

两个形状相同的矩阵按元素一个一个加、减、乘、除。

2.2.2 向量级运算

向量有内积和外积,也被称为点积和叉积。前者计算出一个标量,后者计算出一个方阵。以 x,y,zx,y,z 三个 nn 维向量,实数 λ\lambda 为例,介绍以下三个知识点。

向量的内积

  1. [x,y]=[y,x][x, y] = [y, x]
  2. [λx,y]=λ[x,y][\lambda x, y] = \lambda [x, y]
  3. [x+y,z]=[x,z]+[y,z][x + y, z] = [x, z] + [y, z]
  4. [x,x]0[x, x] \geq 0,且当 x0x \ne 0 时有 [x,x]>0[x, x] > 0

向量的长度

  1. 非负性:当 x0x \ne 0 时,x>0\|x\| > 0;当 x=0x = 0 时,x=0\|x\| = 0
  2. 齐次性:λx=λx\|\lambda x\| = \|\lambda\|\|x\|
  3. 三角不等式:x+yx+y\|x + y\| \le \|x\| + \|y\|

向量的夹角

  1. x=1\|x\| = 1 时,称 xx 为单位向量
  2. x0,y0\|x\| \ne 0, \|y\| \ne 0 时,θ=arccos[x,y]xy\theta = \arccos \frac{[x, y]}{\|x\|\|y\|}

2.2.3 矩阵级运算

矩阵乘法算律:

矩阵乘法算律

我们分别解释上面的「矩阵乘法」算律:

(1) 结合律。

(2) 分配率。

(3) 常数因子可以随意交换顺序。

(4) 单位阵可以随意交换顺序或直接省略。

(5) 幂运算。由于有结合律存在,因此当 A、B 两个方阵可交换时,有幂运算规律。

注意:

  • 矩阵乘法的基本规则。AB=CAB=Ccijc_{ij}AA 的第 ii 行与 BB 的第 jj 列元素依次相乘并求和的结果。

  • 矩阵乘法没有交换律。ABAB 称为 AA 左乘 BB。交换成立的前提是 AABB 左乘和右乘合法相等才可以。

2.2.4 矩阵的转置

矩阵转置算律:

矩阵转置算律

证明 (4)。左边的 cijc_{ij} 其实应该是 ABABcjic_{ji} ,对应 AA 的第 jj 行与 BB 的第 ii 列,那么反过来对于 ijij 就是 BB 转置的第 ii 行与 AA 转置的第 jj 列。

对称矩阵。对于一个方阵 AA,若有 A=ATA = A^T 则称 AA 为对称阵。给一个对阵矩阵的例题:

对称矩阵 - 例题

2.2.5 方阵的行列式

行列式算律:

行列式算律

伴随矩阵:

AA=AA=AEAA^* = A^* A = \left | A \right |E

伴随矩阵

2.3 逆矩阵

定义:

  • 逆矩阵。对于矩阵 AA,若有 AB=BA=EAB = BA = E ,则称 BBAA 的逆矩阵。
  • 奇异矩阵。对于方阵 AA,若 A=0|A| = 0,则 AA 为奇异矩阵。
  • 非奇异矩阵。对于方阵 AA,若 A0|A| \ne 0,则 AA 为非奇异矩阵。

性质:

  • 唯一性。如果矩阵 AA 可逆,则 AA 的逆矩阵是唯一的。
  • 行列式。如果矩阵 AA 可逆,则 A0|A| \ne 0
  • 矩阵可逆的必要条件。若 AB=EAB=E (或 BA=EBA = E),则 AA 可逆且 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.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 基本概念

定义。我们从矩阵的初等行变换出发定义矩阵的初等变换,共有以下三种行变换:

  1. 第 i 行与第 j 行对换。rirjr_i \leftrightarrow r_j
  2. 第 i 行乘以一个常数 k。riri×k (k0)r_i \leftarrow r_i \times k\ (k \neq 0)
  3. 第 i 行加上第 j 行的 k 倍。riri+krjr_i \leftarrow r_i + kr_j

将上述的行变换全部置换为列变换,就是矩阵的初等列变换。初等行变换与初等列变化统称初等变换。注意三种变换都是可逆的,也就是说所有的变换都是等价的。

符号表示。为了更方便的表示和书写,我们定义以下矩阵初等变换的符号,对于矩阵 A 和矩阵 B 而言:

  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

初等变换的数学意义。所有的变换都等价于在原始矩阵上左乘或右乘一个初等矩阵 E(Elementary Matrix)。注意初等矩阵表示对单位阵进行上述三种变换后的结果。例如:

  • Am×nA_{m\times n} 施行一次初等行变换,相当于在 AA 的左边乘以相应的 mm 阶初等矩阵。

  • Am×nA_{m\times n} 施行一次初等列变换,相当于在 AA 的右边乘以相应的 nn 阶初等矩阵。

性质。初等变换拥有三大特性:

  1. 自反性:AAA \sim A
  2. 对称性:若 ABA \sim B,则 BAB \sim A
  3. 传递性:若 ABA\sim BBCB\sim C ,则 ACA \sim C

三种形式的矩阵

  1. 行阶梯形矩阵。可划出一条阶梯线,线下方全为零;每个台阶高度只有一行,台阶数即是非零行的行数,阶梯线的竖线后面的第一个元素为非零元。例如:

    (24104051730001300000)\begin{pmatrix} \underline{2} & 4 & -1 & 0 & 4 \\0 & \underline{5} & -1 & -7 & 3 \\0 & 0 & 0 & \underline{1} & -3 \\0 & 0 & 0 & 0 & 0 \end{pmatrix}

  2. 行最简形矩阵。是行阶梯形矩阵,且非零行的第一个非零元为1,它所在列的其他元素都为零。例如:

    (10104011030001300000)\begin{pmatrix} 1 & 0 & -1 & 0 & 4 \\ 0 & 1 & -1 & 0 & 3 \\ 0 & 0 & 0 & 1 & -3 \\ 0 & 0 & 0 & 0 & 0 \end{pmatrix}

  3. 标准形。左上角是一个单位矩阵,其余元素全是零。m×nm \times n 的矩阵 AA 总可经过初等变换化为标准形。例如:

    F=(ErOOO)m×nF = \begin{pmatrix} E_r & O \\ O & O \end{pmatrix}_{m \times n}

    此标准形由 m,n,rm, n, r 三个数唯一确定,其中 rr 就是行阶梯形矩阵中非零行的行数。

3.1.2 与逆矩阵的关系

矩阵初等变换的存在性定理。对于 Am×nA_{m\times n}Bm×nB_{m\times n}

  1. ArB    A \stackrel{r}{\sim} B \iff 存在 m 阶可逆阵 P 使得 PA=B
  2. AcB    A \stackrel{c}{\sim} B \iff 存在 n 阶可逆阵 Q 使得 AQ=B
  3. AB    A \sim B \iff 存在 m 阶可逆阵 P 及 n 阶可逆阵 Q 使得 PAQ=B

如何求解变换矩阵呢?由于 (2) 中的 Q 可以通过转置转化为求解 (1) 中的 P,因此我们以求解上述 (1) 中的 P 为例:

PA=B    {PA=BPE=P    (AE)r(BP)PA=B \iff\begin{cases}PA=B\\PE=P\end{cases} \iff(A\quad E) \stackrel{r}{\sim} (B \quad P)

即对 (AE)(A \quad E) 作初等行变换,当把 AA 变为 BB 时,EE 就变为了需要求解的可逆阵 PP

方阵可逆的等价推导。方阵 A 可逆     \iff ArEA \stackrel{r}{\sim} E。于是证明方阵 AA 可逆就又多了一个策略,即将 AA 经过有限次的初等行变换之后变成了单位阵,就可以说明 A 是可逆矩阵。求解过程如下:

A 为可逆方阵    {A1A=EA1E=A1    (AE)r(EA1)A\text{ 为可逆方阵} \iff\begin{cases}A^{-1}A=E\\A^{-1}E=A^{-1}\end{cases} \iff(A\quad E) \stackrel{r}{\sim} (E \quad A^{-1})

即对 (AE)(A \quad E) 作初等行变换,当把 AA 变为 EE 时,EE 就变为了 A1A^{-1}。显然此法不仅可以用来证明一个可逆阵,也可以顺带计算出其逆矩阵。

3.1.3 让我们解个线性方程组吧

问题。已知矩阵 A,BA,B,且 AX=BAX=B,现在需要求解 XX 矩阵。

求解。首先需要证明 AA 可逆,然后计算 A1BA^{-1}B 即为所求。采用本节的知识:如果 ArEA \stackrel{r}{\sim} E,则 AA 可逆,即 PA=EPA=E。还需要求 A1BA^{-1}B 怎么办呢?显然可以先算逆矩阵再乘 B,但是!如果我们对 (AB)(A\quad B) 作初等行变换,当那么当 AA 转化为 EE 后,B 就转化为了 A1BA^{-1}B,正好就是我们要求的!非常的巧妙。

补充。上述求解 XX 的过程本质上就是解一个线性方程组,到目前为止我们已经有以下策略了:

  1. 高中学的。消元。
  2. chapter2.3。先求逆矩阵 A1A^{-1},再将 A1A^{-1}BB 相乘。
  3. chapter2.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

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 正交矩阵与正交变换

正交向量。即两向量内积为 0,类似于二维平面中两个垂直的非零向量。

正交向量组。

  • 定义:向量组之间的任意两两向量均正交。
  • 性质:正交向量组一定线性无关。

标准正交基。

  • 定义:是某空间向量的基+正交向量组+每一个向量都是单位向量。

  • 求解方法:施密特正交化求解标准正交基。

一、正交化

正交化

正交化 - 续

二、单位化

单位化

正交矩阵。

  • 定义:满足 ATA=E or AAT=EA^TA=E\ \text{or} \ AA^T=E 的方阵。
  • 定理:正交矩阵的充要条件为矩阵的行/列向量为单位向量且两两正交。

正交变换。

  • 定义:对于正交矩阵 AAy=Axy=Ax 称为称为正交变换。
  • 性质:y=yTy=xTATAx=xTEx=xTx=x||y||=\sqrt{y^Ty}=\sqrt{x^TA^TAx}=\sqrt{x^TEx}=\sqrt{x^Tx}=||x||,即向量经过正交变换之后长度保持不变。

5.2 特征值与特征向量

定义。对于一个 nn 阶方阵 AA,存在一个复数 λ\lambda 和一组 nn 阶非零向量 xx 使得 Ax=λxAx =\lambda x,则称 xx 为特征向量,λ\lambda 为特征值,AλE|A-\lambda E| 为特征多项式。

特征值的性质

  • nn 阶矩阵 AA 在复数范围内含有 nn 个特征值,且:

    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}

  • λ\lambdaAA 的特征值,则 ϕ(λ)\phi{(\lambda)}ϕ(A)\phi{(A)} 的特征值。

特征向量的性质。对于同一个矩阵,不同的 特征值对应的特征向量之间是 线性无关 的。

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 是正半定的,当且仅当所有主子矩阵的行列式都大于或等于零
]]>
@@ -1157,11 +1157,11 @@ - LinearAlgebra - - /GPA/3rd-term/LinearAlgebra/ + DigitalLogicCircuit + + /GPA/3rd-term/DigitalLogicCircuit/ - 线性代数

前言

本篇博客初稿完成于 2024.01.06,即大二上学期期末。参考《工程数学 线性代数》同济第七版。

由于初稿写作时博主的水平有限并且偏向于应试,写作水平多有不足并且内容有所缺失。现在在学习 ML 和 DL 甚至数字图像处理时,几乎满屏都是矩阵。汗颜的是,由于初学时几乎都是死记硬背以及应试,我一度怀疑自己没学过线代🤡,因此这篇博客会持续更新。

后续的更新会对内容进行整合与补充,包括二次型、线性空间和线性变换。同时偏向实际的应用,包括 AI 相关的矩阵计算和矩阵微积分、数字图像处理相关的变换策略。打星 ()(*) 的内容表示个人认为比较重要的部分。

为什么要学习线性代数?为什么会有线性代数?

线性代数是为了解决多元线性方程组而诞生的。(2024.01.06)

真的是这样吗?看到一篇博客是从程序语言角度进行理解的,还挺有意思:8分钟带你彻底弄懂《线性代数》。(2024.10.21)

1 行列式

1.1 基本概念

全排列。当一个序列含有 nn 个数并且序列中每一个位置只出现 [1,n][1,n] 一次,则称该序列为全排列。

逆序数。一个排列中每一个元素之前比其大的元素数量之和。

对换。顾名思义就是指排列中两个元素进行交换的操作。有以下两个结论:

  1. 一个排列中两个元素对换,排列逆序数的奇偶性改变。
  2. 奇排列对换成标准排列的对换次数为奇数,偶排列对换成标准排列的对换次数为偶数。(标准排列就是 nn 个数从小到大升序排列)

1.2 定义 *

我们以 n 阶行列式为例。nn 阶行列式的值为 n!n! 个项之和,每一项的组成方式为:每行选一个元素,每列选一个元素,这些元素之积,符号为

(1)N(row)+N(col)(-1)^{N(row)+N(col)}

1.3 性质

行列式的性质可以用来简化求值,下面简单介绍一下 5 个常见的行列式性质及其推论,相关的证明都可以用定义证出来,故省略。

  1. 行列式与其转置行列式相等。

  2. 对换行列式的两个行或者列,行列式的符号改变。

    • 推论。若行列式有两行或两列完全相同,则行列式的值为 0。
  3. 若行列式的某一行/列 ×k\times k,则行列式的值也 ×k\times k

    • 推论一。行列式的某一行/列中的公因子可以提到行列式之外。
    • 推论二。若行列式有两行/列成比例,则行列式的值为零。
  4. 若行列式的某一行/列都是两数之和,则可以拆分成两个行列式之和。

  5. 把行列式的某一行/列乘一个常数累加到另一行/列上,行列式的值不变。

关于行列式求值的技巧。在一开始对换行或者列的时候,尽可能保证左上角是数字 1,从而配凑出上三角进而直接用对角线之积求值。

1.4 按行/列展开

本目简单介绍一下行列式求值的另一个策略:按行/列展开。我们用 D 表示行列式 (Determinant)。用 MijM_{ij} 表示余子式,即行列式去掉 DijD_{ij} 元素所在的行和列后剩余元素拼接起来的行列式。用 AijA_{ij} 表示代数余子式,其中 Aij=(1)i+jMijA_{ij}=(-1)^{i+j}M_{ij}

若行列式的某一行/列只有一个元素不为零,则有:

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}

若行列式的某一行/列有多个元素不为零,则有:

D=i=1naxiAxiD =\sum_{i = 1}^n a_{xi}A_{xi}

将展开的那一行/列通过加法原理进行拆分,然后利用上述只有一个元素不为零时的一般情况进行证明即可。

已知 n 阶行列式 D,按第 xx 行展开后有 D=i=1naxiAxiD=\sum_{i=1}^n a_{xi}A_{xi},现在将 axia_{xi} 替换为 ayia_{yi}xyx\ne y,则 i=1nayiAxi=0\sum_{i=1}^n a_{yi}A_{xi}=0。道理很简单,现在求解的值其实也是一个行列式,并且这个行列式有两行/列的元素完全相等,那么显然的行列式的值就是 0。例如下面这道题:显然 (1) 的结果为 0,(2) 只需要配凑一下即可。

例题

特殊的行列式

下面补充几个特殊的行列式及其求值方法。

分块行列式

分块行列式

0 在左下或右上就是左上角与右下角行列式之积(D=D1D2D=D_1D_2),0 在左上或右下就是左下角与右上角行列式之积加上符号判定。

证明。分区域转换为上三角即可。

2n 阶行列式

2n 阶行列式

先行对换再列对换,通过分块行列式和数学归纳法,可得行列式的值是一个等比数列。

范德蒙德行列式 *

范德蒙德行列式

证明。首先从最后一行开始,依次减去前一行的 x1x_1 倍,凑出第一列一个元素不为零的情况,最后通过数学归纳法即可求解。项数为 Cn2C_n^2

2 矩阵

2.1 定义

相较于行列式是一个数,矩阵就是一个数表。下面补充几个常见的名词:

  • 方阵。若行列数相等均为 n,则可以称为方阵或 n 阶矩阵或 n 阶方阵。
  • 对角阵。即方阵的非主对角线元素均为 0。符号表示为 Λ=diag(λ1,λ2,,λn)\Lambda=diag(\lambda_1,\lambda_2,\cdots,\lambda_n)
  • 单位阵。即方阵的主对角线全 1,其余全 0。符号表示为 E=diag(1,1,,1)E=diag(1,1,\cdots,1)
  • 纯量矩阵。主对角线上元素全为 λ\lambda ,其余全 0。符号表示为 S=diag(λ,λ,,λ)S=diag(\lambda,\lambda,\cdots,\lambda)

2.2 运算

2.2.1 元素级运算

两个形状相同的矩阵按元素一个一个加、减、乘、除。

2.2.2 向量级运算

向量有内积和外积,也被称为点积和叉积。前者计算出一个标量,后者计算出一个方阵。以 x,y,zx,y,z 三个 nn 维向量,实数 λ\lambda 为例,介绍以下三个知识点。

向量的内积

  1. [x,y]=[y,x][x, y] = [y, x]
  2. [λx,y]=λ[x,y][\lambda x, y] = \lambda [x, y]
  3. [x+y,z]=[x,z]+[y,z][x + y, z] = [x, z] + [y, z]
  4. [x,x]0[x, x] \geq 0,且当 x0x \ne 0 时有 [x,x]>0[x, x] > 0

向量的长度

  1. 非负性:当 x0x \ne 0 时,x>0\|x\| > 0;当 x=0x = 0 时,x=0\|x\| = 0
  2. 齐次性:λx=λx\|\lambda x\| = \|\lambda\|\|x\|
  3. 三角不等式:x+yx+y\|x + y\| \le \|x\| + \|y\|

向量的夹角

  1. x=1\|x\| = 1 时,称 xx 为单位向量
  2. x0,y0\|x\| \ne 0, \|y\| \ne 0 时,θ=arccos[x,y]xy\theta = \arccos \frac{[x, y]}{\|x\|\|y\|}

2.2.3 矩阵级运算

矩阵乘法算律:

矩阵乘法算律

我们分别解释上面的「矩阵乘法」算律:

(1) 结合律。

(2) 分配率。

(3) 常数因子可以随意交换顺序。

(4) 单位阵可以随意交换顺序或直接省略。

(5) 幂运算。由于有结合律存在,因此当 A、B 两个方阵可交换时,有幂运算规律。

注意:

  • 矩阵乘法的基本规则。AB=CAB=Ccijc_{ij}AA 的第 ii 行与 BB 的第 jj 列元素依次相乘并求和的结果。

  • 矩阵乘法没有交换律。ABAB 称为 AA 左乘 BB。交换成立的前提是 AABB 左乘和右乘合法相等才可以。

2.2.4 矩阵的转置

矩阵转置算律:

矩阵转置算律

证明 (4)。左边的 cijc_{ij} 其实应该是 ABABcjic_{ji} ,对应 AA 的第 jj 行与 BB 的第 ii 列,那么反过来对于 ijij 就是 BB 转置的第 ii 行与 AA 转置的第 jj 列。

对称矩阵。对于一个方阵 AA,若有 A=ATA = A^T 则称 AA 为对称阵。给一个对阵矩阵的例题:

对称矩阵 - 例题

2.2.5 方阵的行列式

行列式算律:

行列式算律

伴随矩阵:

AA=AA=AEAA^* = A^* A = \left | A \right |E

伴随矩阵

2.3 逆矩阵

定义:

  • 逆矩阵。对于矩阵 AA,若有 AB=BA=EAB = BA = E ,则称 BBAA 的逆矩阵。
  • 奇异矩阵。对于方阵 AA,若 A=0|A| = 0,则 AA 为奇异矩阵。
  • 非奇异矩阵。对于方阵 AA,若 A0|A| \ne 0,则 AA 为非奇异矩阵。

性质:

  • 唯一性。如果矩阵 AA 可逆,则 AA 的逆矩阵是唯一的。
  • 行列式。如果矩阵 AA 可逆,则 A0|A| \ne 0
  • 矩阵可逆的必要条件。若 AB=EAB=E (或 BA=EBA = E),则 AA 可逆且 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.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 基本概念

定义。我们从矩阵的初等行变换出发定义矩阵的初等变换,共有以下三种行变换:

  1. 第 i 行与第 j 行对换。rirjr_i \leftrightarrow r_j
  2. 第 i 行乘以一个常数 k。riri×k (k0)r_i \leftarrow r_i \times k\ (k \neq 0)
  3. 第 i 行加上第 j 行的 k 倍。riri+krjr_i \leftarrow r_i + kr_j

将上述的行变换全部置换为列变换,就是矩阵的初等列变换。初等行变换与初等列变化统称初等变换。注意三种变换都是可逆的,也就是说所有的变换都是等价的。

符号表示。为了更方便的表示和书写,我们定义以下矩阵初等变换的符号,对于矩阵 A 和矩阵 B 而言:

  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

初等变换的数学意义。所有的变换都等价于在原始矩阵上左乘或右乘一个初等矩阵 E(Elementary Matrix)。注意初等矩阵表示对单位阵进行上述三种变换后的结果。例如:

  • Am×nA_{m\times n} 施行一次初等行变换,相当于在 AA 的左边乘以相应的 mm 阶初等矩阵。

  • Am×nA_{m\times n} 施行一次初等列变换,相当于在 AA 的右边乘以相应的 nn 阶初等矩阵。

性质。初等变换拥有三大特性:

  1. 自反性:AAA \sim A
  2. 对称性:若 ABA \sim B,则 BAB \sim A
  3. 传递性:若 ABA\sim BBCB\sim C ,则 ACA \sim C

三种形式的矩阵

  1. 行阶梯形矩阵。可划出一条阶梯线,线下方全为零;每个台阶高度只有一行,台阶数即是非零行的行数,阶梯线的竖线后面的第一个元素为非零元。例如:

    (24104051730001300000)\begin{pmatrix} \underline{2} & 4 & -1 & 0 & 4 \\0 & \underline{5} & -1 & -7 & 3 \\0 & 0 & 0 & \underline{1} & -3 \\0 & 0 & 0 & 0 & 0 \end{pmatrix}

  2. 行最简形矩阵。是行阶梯形矩阵,且非零行的第一个非零元为1,它所在列的其他元素都为零。例如:

    (10104011030001300000)\begin{pmatrix} 1 & 0 & -1 & 0 & 4 \\ 0 & 1 & -1 & 0 & 3 \\ 0 & 0 & 0 & 1 & -3 \\ 0 & 0 & 0 & 0 & 0 \end{pmatrix}

  3. 标准形。左上角是一个单位矩阵,其余元素全是零。m×nm \times n 的矩阵 AA 总可经过初等变换化为标准形。例如:

    F=(ErOOO)m×nF = \begin{pmatrix} E_r & O \\ O & O \end{pmatrix}_{m \times n}

    此标准形由 m,n,rm, n, r 三个数唯一确定,其中 rr 就是行阶梯形矩阵中非零行的行数。

3.1.2 与逆矩阵的关系

矩阵初等变换的存在性定理。对于 Am×nA_{m\times n}Bm×nB_{m\times n}

  1. ArB    A \stackrel{r}{\sim} B \iff 存在 m 阶可逆阵 P 使得 PA=B
  2. AcB    A \stackrel{c}{\sim} B \iff 存在 n 阶可逆阵 Q 使得 AQ=B
  3. AB    A \sim B \iff 存在 m 阶可逆阵 P 及 n 阶可逆阵 Q 使得 PAQ=B

如何求解变换矩阵呢?由于 (2) 中的 Q 可以通过转置转化为求解 (1) 中的 P,因此我们以求解上述 (1) 中的 P 为例:

PA=B    {PA=BPE=P    (AE)r(BP)PA=B \iff\begin{cases}PA=B\\PE=P\end{cases} \iff(A\quad E) \stackrel{r}{\sim} (B \quad P)

即对 (AE)(A \quad E) 作初等行变换,当把 AA 变为 BB 时,EE 就变为了需要求解的可逆阵 PP

方阵可逆的等价推导。方阵 A 可逆     \iff ArEA \stackrel{r}{\sim} E。于是证明方阵 AA 可逆就又多了一个策略,即将 AA 经过有限次的初等行变换之后变成了单位阵,就可以说明 A 是可逆矩阵。求解过程如下:

A 为可逆方阵    {A1A=EA1E=A1    (AE)r(EA1)A\text{ 为可逆方阵} \iff\begin{cases}A^{-1}A=E\\A^{-1}E=A^{-1}\end{cases} \iff(A\quad E) \stackrel{r}{\sim} (E \quad A^{-1})

即对 (AE)(A \quad E) 作初等行变换,当把 AA 变为 EE 时,EE 就变为了 A1A^{-1}。显然此法不仅可以用来证明一个可逆阵,也可以顺带计算出其逆矩阵。

3.1.3 让我们解个线性方程组吧

问题。已知矩阵 A,BA,B,且 AX=BAX=B,现在需要求解 XX 矩阵。

求解。首先需要证明 AA 可逆,然后计算 A1BA^{-1}B 即为所求。采用本节的知识:如果 ArEA \stackrel{r}{\sim} E,则 AA 可逆,即 PA=EPA=E。还需要求 A1BA^{-1}B 怎么办呢?显然可以先算逆矩阵再乘 B,但是!如果我们对 (AB)(A\quad B) 作初等行变换,当那么当 AA 转化为 EE 后,B 就转化为了 A1BA^{-1}B,正好就是我们要求的!非常的巧妙。

补充。上述求解 XX 的过程本质上就是解一个线性方程组,到目前为止我们已经有以下策略了:

  1. 高中学的。消元。
  2. chapter2.3。先求逆矩阵 A1A^{-1},再将 A1A^{-1}BB 相乘。
  3. chapter2.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

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 正交矩阵与正交变换

正交向量。即两向量内积为 0,类似于二维平面中两个垂直的非零向量。

正交向量组。

  • 定义:向量组之间的任意两两向量均正交。
  • 性质:正交向量组一定线性无关。

标准正交基。

  • 定义:是某空间向量的基+正交向量组+每一个向量都是单位向量。

  • 求解方法:施密特正交化求解标准正交基。

一、正交化

正交化

正交化 - 续

二、单位化

单位化

正交矩阵。

  • 定义:满足 ATA=E or AAT=EA^TA=E\ \text{or} \ AA^T=E 的方阵。
  • 定理:正交矩阵的充要条件为矩阵的行/列向量为单位向量且两两正交。

正交变换。

  • 定义:对于正交矩阵 AAy=Axy=Ax 称为称为正交变换。
  • 性质:y=yTy=xTATAx=xTEx=xTx=x||y||=\sqrt{y^Ty}=\sqrt{x^TA^TAx}=\sqrt{x^TEx}=\sqrt{x^Tx}=||x||,即向量经过正交变换之后长度保持不变。

5.2 特征值与特征向量

定义。对于一个 nn 阶方阵 AA,存在一个复数 λ\lambda 和一组 nn 阶非零向量 xx 使得 Ax=λxAx =\lambda x,则称 xx 为特征向量,λ\lambda 为特征值,AλE|A-\lambda E| 为特征多项式。

特征值的性质

  • nn 阶矩阵 AA 在复数范围内含有 nn 个特征值,且:

    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}

  • λ\lambdaAA 的特征值,则 ϕ(λ)\phi{(\lambda)}ϕ(A)\phi{(A)} 的特征值。

特征向量的性质。对于同一个矩阵,不同的 特征值对应的特征向量之间是 线性无关 的。

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 是正半定的,当且仅当所有主子矩阵的行列式都大于或等于零
]]>
+ 数字逻辑电路

1 概论

1.1 数字信号 | 数字电路

1.1.1 数字技术的发展及其应用

  1. 电流控制器件:电子管、晶体管(二极管、三极管)、半导体集成电路

  2. EDA (Electronic Design Automation) 技术(硬件设计软件化):设计:EWB or Verilog、仿真、下载、验证结果

1.1.2 数字集成电路的分类及特点

  1. 数字集成电路的分类

    1. 从结构特点及其对输入信号的响应规则角度:组合逻辑电路、时序逻辑电路

    2. 从电路的形式角度:集成电路、分立电路

    3. 从器件的角度:TTL 电路、CMOS 电路

    4. 从集成度(每一个芯片所包含的门个数)的角度:小规模、中规模、大规模、超大规模、甚大规模

  2. 数字集成电路的特点

    1. 稳定性高:抗干扰能力强
    2. 易于设计:对 0 和 1 表示的信号进行逻辑运算和处理
    3. 便于集成:体积小、通用性好、成本低
    4. 可编程性:可实现硬件设计软件化
    5. 高速度、低功耗
    6. 便于存储、传输、处理
  3. 数字电路的 分析设计 与测试

    1. 分析方法
      • 目标:确定输入与输出
      • 工具:逻辑代数
      • 方法:真值表、功能表、逻辑表达式和波形图
    2. 设计方法
      • 从功能要求出发,选择合适的逻辑器件进行设计
      • 设计方式:传统的设计方法 or 基于 EDA 的软件设计方法
    3. 测试技术

1.1.3 模拟信号与数字信号

  1. 模拟信号:时间和数值均 连续 变化的信号

  2. 数字信号:时间和数值均 离散 变化的信号

  3. 模拟量的数字表示:模数转换,即将连续的模拟信号经过 采样与编码 转化为数字信号

    • 首先对时间离散
    • 然后对幅值离散
    • 最后对得到的数字量进行编码

1.1.4 数字信号的描述方法

  1. 二值数字逻辑和逻辑电平

    二值数字逻辑:0 和 1 两种状态(定量)

    逻辑电平:高电压和低电压(定性)

    正逻辑关系表(负逻辑相反):

    电压(V)二值逻辑电平
    3.5~51H(高电压)
    0~1.50L(低电压)
  2. 数字波形

  3. 实际数字信号波形

  4. 波形图、时序图或定时图

1.2 数制

(N)r=i=Ki ri(N)_r =\sum_{i =-\infty}^\infty K_i\ r^i

1.2.1 十进制

1.2.2 二进制

优点:易于表达;二进制数字电路 逻辑简单,所用元件少;基本运算 规则简单,运算操作方便

波形表示:应用比如“计数器”

数据传输:应用比如“串行传输”

1.2.3 十-二进制之间的转换

十进制小数转化为二进制:将小数部位不断 ×2,取整数,直到没有小数部分为止

1.2.4 十六进制和八进制

二进制转十六进制:从右往左每四位换算成十六进制

二进制转八进制:从右往左每三位换算成八进制

1.3 二进制数的算术运算

1.3.1 无符号二进制数的运算

1.3.2 有符号二进制数的运算

定义:其实就是多了一个符号位,且不可以省略。其中 0 表示正数,1 表示负数:

(+11)D=(01011)B(11)D=(11011)B\begin{aligned}(+11)_D =(01011)_B \\(-11)_D =(11011)_B \\\end{aligned}

补码、反码和原码:

  • 对于正数,补码反码原码全部一样

  • 对于负数,反码为:符号位不动,原码按位取反;补码为:反码最低位+1 即可

加法:与十进制竖式计算类似

减法:与十进制竖式计算类似

溢出:是因为数值位不够了,解决方法是进行 位扩展

溢出的判别:

  • 两个正数的求和,得到的补码的最高位如果为 1,则溢出

  • 两个负数的求和,得到的补码的最高位如果为 0,则溢出

1.4 二进制代码

1.4.1 二/十进制码

其实就是在表示 0-15 的十六个二进制里面,按照不同的规则选取 10 个二进制数来进行转换

  1. 有权码 - 最接近逻辑的:8421BCD 码
  2. 无权码

1.4.2 格雷码

1.4.3 ASCII 码

1.5 二值逻辑变量与基本逻辑运算

1.5.1 常见逻辑符号示例

运算类型逻辑符号逻辑表达式
image-20231008095031818Y=ABY=AB
image-20231008095047759Y=A+BY=A+B
image-20231008094808523Y=AY=\overline{A}
与非image-20231008095111128Y=ABY=\overline{AB}
或非image-20231008095809755Y=A+BY=\overline{A+B}
异或image-20231008095902796Y=AB+AB=ABY=\overline A B+A \overline B=A \oplus B
同或image-20231008095922663Y=AB+AˉBˉ=ABY = AB + \bar A \bar B = A\odot B
与或非image-20231008095947154Y=AB+CDY=\overline{AB+CD}

1.5.2 使用逻辑函数表示实际问题

实际问题图片示例变量表示列真值表逻辑函数
image-20231008102102546image-20231008102037276image-20231008101253710image-20231008101303842image-20231008101318582

1.6 逻辑函数及其表示方法

描述输入逻辑变量和输出逻辑变量之间的因果关系,称为逻辑函数

1.6.1 逻辑函数的几种表示方法

方法示例
真值表image-20231008110656451
逻辑函数表达式image-20231008110713908
逻辑图image-20240116131615935
波形图image-20231008111013474

1.6.2 逻辑函数表示方法之间的转换

真值表到逻辑图的转换

  • 查看真值表

    image-20231008112605100
  • 根据真值表写出逻辑表达式

    image-20231008112628066
  • 化简(上式不用化简)

  • 绘制逻辑图

    image-20231008112708161

逻辑图到真值表的转换

  • 根据逻辑图逐级写出表达式

    image-20231008112800422
  • 化简

  • 代入所有输入变量求真值表

    image-20231008112839220

2 逻辑代数 | 硬件描述语言基础

2.1 基本定律和规则

2.1.1 基本定律和恒等式

image-20231008114849627

image-20231008114943429

2.1.2 基本规则

  1. 代入规则 - 类似于换元

    image-20231008115435355

  2. 反演规则(获得反函数 Y\overline Y

    觉得烦可以直接进行取反运算,简单明了不会错

    • 对于任意一个逻辑表达式 L,与门 & 或门取反,变量取反,0 & 1 取反
    • 保持原来的运算优先顺序(即如果在原函数表达式中,AB 之间先运算,再和其他变量进行运算,那么非函数的表达式中,仍然是 AB 之间先运算)
    • 对于反变量以外的非号应保留不变

    image-20231008115328932

    image-20231008115337125

  3. 对偶规则(获得对偶式 LL'

    • 对于任何逻辑函数式:与门、或门取反,0、1 取反

2.2 逻辑函数表达式的形式

2.2.1 基本形式

  1. 与或表达式:若干个与项相或

    image-20231013094928938

  2. 或与表达式:若干个或项相与

    image-20231013094938441

2.2.2 最小项与最小项表达式

  1. 最小项的定义和性质:n 个变量的最小项一共有 2n2^n

  2. 最小项表达式:所有的最小项相或

    image-20231013100105922

2.2.3 最大项与最大项表达式

  1. 最大项的定义和性质:n 个变量的最大项一共有 2n2^n

  2. 最大项表达式:所有的最大项相与

    image-20240116134303065

2.2.4 最大项和最小项的关系

mi=Mim_i =\overline {M_i}

2.3 逻辑函数的代数化简法

为什么要学化简?因为化简之后可以减少门的使用,从而增强电路可靠性、降低成本

2.3.1 逻辑函数的最简形式

最简与或表达式:包含的与项数最少,且每个与项中变量数最少的与或表达式

2.3.2 逻辑函数的代数化简法

  1. 逻辑函数的 化简

    方法逻辑函数证明
    并项法A+A=1A+\overline A = 1显然
    吸收法A+AB=AA+AB = A提取公因子
    消去法A+AB=A+BA+\overline A B = A+B摩根定律使用两次
    配项法A=A(B+B)A = A(B + \overline B)显然
  2. 逻辑函数形式的 变换

    使用场景:通常在一片集成电路芯片中只有一种门电路,为了减少门电路的种类,需要对逻辑函数表达式进行变换

    变换方法:常常使用两次取反的套路进行变换

    image-20231013112834593

2.4 逻辑函数的卡诺图化简法

2.4.1 用卡诺图表示逻辑函数

首先写出逻辑函数的表达式并且转化为最小项表达式,最后将最小项填入相应的矩阵中即可

2.4.2 用卡诺图化简逻辑函数

尽可能使得圈出来的 2k2^k 圈中包含的数尽可能的多,即让 kk 尽可能的大。注意:圈中的数全部都得是最小项的数

2.5 Verilog HDL 基础

为了从软件代码的角度描述电路,从下面三个方面介绍如何用 Verilog 描述数字逻辑电路。

2.5.1 门级描述

门级元件中,第一个位置是输出变量,之后的都是输入变量,可解释为:多输入门

门级元件元件符号
and
or
not
与非nand
或非nor
异或xor
同或xnor

2.5.2 数据流描述

简单的概括就是使用相关的位运算进行表述,因为电路逻辑本就是二元逻辑,因此位运算就刚好匹配。在使用数据流进行电路描述时,采用的语句都是连续赋值语句,由 assign 关键词开始,多条 assign 语句是 并行 运行的

需要注意的是,在连续赋值语句中,被赋值的变量一定是 wire 的 线网 类型的变量,示例如下

1
2
// 其中 Y 为 wire 类型的变量
assign Y = (~S & D0) | (S & D1)

2.5.3 行为描述

简单的概括就是使用底层语言进行编程,类似于最开始的 C 语言。使用行为描述语句进行描述时,使用 always 关键字开始变量赋值逻辑,多条 always 语句是 串行 运行的

需要注意的是,在行为描述语句中,被赋值的变量一定是 reg 等 寄存器 类型的变量,这与上述数据流描述的方式不同,示例如下

1
2
3
4
// 其中 Y 为 reg 类型的变量
always@(*)// * 为敏感变量,对于组合电路而言,所有的输入都是敏感变量
if (S) Y = D1;
else Y = D0;

3 逻辑门电路

3.1 简介

MOS 管含有 NMOS 管和 PMOS 管,NMOS 管与 PMOS 管的组合称为互补 MOS,或称为 CMOS 电路。

3.2 基本 CMOS 逻辑门电路

附上启蒙的博客:MOS 管及简单 CMOS 逻辑门电路原理图解析!

器件电路
开关image-20231201090156879
反相器(非门)image-20231201090250860
与非门image-20231201090233084
或非门image-20231201090319423
传输门(开关)image-20231222104533397
与门image-20240117003028351
或门image-20240117003058029

应用示例

解读的逻辑其实很简单,在理解之前,应该首先观看上面给出的连接中的 MOS 电路的简化版,从而理解电路的正确结构!即,每一个 MOS 管都理解为一个开关,何时闭合与断开完全取决于相应的 MOS 管的种类与电平,如果是 NMOS 管,即箭头指向左边的,为高电平导通,PMOS 管则相反,只需要知道此电路基本逻辑,那么接下来的分析结果就是水到渠成的事。

需要知道一个理念就是,两个电路如果是并联的存在,那么逻辑表达式就是或,简称为 并联相或;对应的,两个电路如果是串联的存在,那么逻辑表达式就是与,简称为 串联相与。最后需要补充一点的就是关于取反的辨识,我们知道一个反相器 MOS 管的逻辑是非常简单的,就是一个 NMOS 管和一个 PMOS 管的组合,那么只需要在分析多个线路是串联还是并联的关系之后,最后经过一个反相器就是一个 取反 逻辑。

电路逻辑表达式功能描述
image-20231201090643503image-20231201090727291异或门
image-20240117103214449L=(BC+D)AL=\overline{(BC+D)A}
image-20240117103238963L=(A+B)X=(A+B)AB=ABL=\overline{(A+B)X}=\overline{(A+B)\overline{AB}}=A\odot B同或门
image-20231201091736402image-20231201091750341异或门
image-20231201091831389image-202312010918589292 选 1 数据选择器

4 组合逻辑电路

4.1 分析策略

组合逻辑电路只取决于实时输入从而给出相应的输出,与之前的运行结果无关。没有反馈和记忆单元。分析流程如下:

  1. 由逻辑图得到 逻辑表达式
  2. 化简和变换
  3. 真值表
  4. 根据真值表(或者波形图)分析电路功能

4.2 设计方法

4.2.1 设计过程

  1. 明确逻辑含义:确定输入输出并定义逻辑状态的含义
  2. 列出真值表:根据逻辑描述写出真值表
  3. 写出逻辑表达式:由真值表写出逻辑表达式,真值取原、假值取反
  4. 化简逻辑表达式:代数化简法 or 卡诺图化简法
  5. 画出逻辑图:使用相应的门级元件进行组合连接

4.2.2 优化实现

电路类型优化策略电路图优化结果
单输出电路统一元件类型image-20231117112320588见左图文字
多输出电路共享相同逻辑项image-20231117112426358见左图文字
多级逻辑电路(限定入数)提取公因项image-20231117112559081见左图文字
多级逻辑电路(限定入数)提取公因项image-20231117112618856见左图文字

4.3 竞争与冒险

为什么会产生?门级元件的延时效应。

如何消去呢?有三种方法:

  1. 消除互补变量。

  2. 增加乘积项,避免互补项相加。

  3. 输出端并联电容器。如下图:

输出端并联电容器

4.4 典型电路示例

举几个典型的组合逻辑电路。

4.4.1 编码器

普通编码器:只允许有一个输入,从而进行编码,一旦出现多输入就会发生错误。

优先编码器:无论多少输入,都会按照一开始设定的优先级进行最高等级的那一个信号位的编码。

名称型号逻辑符号功能分析逻辑图
4-2 优先编码器74LS002 片 7400(4 个 2 输入与非门)实现需要将 4-2 优先编码器的两个逻辑函数转化为与非式,从而进行电路逻辑的搭建。化简后发现需要 7 个 2 输入与非门,故需要 2 片 7400 才能实现 4-2 线优先编码器
8-3 优先编码器CD4532image-20231208104642447除了 8 个输入端与 3 个输出端,还有 EI、EO 与 GS 端。其中 GS 是用来标明当前电路是否处于工作状态的,即如果没有输入端为有效信号,GS 就是低电平,反之则是高电平。而 EI 与 EO 是为了电路扩展而诞生的,当 EI 为高电平且没有任何输入的情况下,EO 也是 1,此时的 4532 就相当于一根导线,从而可以进行片子的扩展由于有现成的集成电路板,故就是逻辑符号
16-4 优先编码器CD45322 片 4532 实现首先确保 EI 始终为高电平。输出后三位 就是两个 4532 片子的 3 输出分别或的结果,最高位的输出 是高位片的 GS 端的结果image-20240117120736794
32-5 优先编码器74LS00+CD45321 片 7400+4 片 4532 实现首先确保 EI 始终为高电平。输出后三位 就是四个 4532 片子的 3 输出分别或的结果,最高位的两个输出 取决于 4 个片子 GS 端 4-2 优先编码的结果。image-20240117121434992

4.4.2 译码器/数据分配器

名称型号逻辑符号功能分析逻辑图
2-4 译码器74X139image-20231208101820876使能端有效时。按照对应的输出给出相应输出的低电平信号由于有现成的集成电路板,故就是逻辑符号
3-8 译码器74X138image-20231208101658175使能端有效时。按照对应的输出给出相应输出的低电平信号由于有现成的集成电路板,故就是逻辑符号
4-16 译码器74X138 或 74X1392 片 74X138 或 5 片 74X139使能端有效时。输入的前三位 分别接入两片 3-8 译码器的输入端,输入的最后一位 接入两片 3-8 译码器的高电平使能端即可;如果用 2-4 译码器来实现,输入的前两位 分别接入四片 2-4 译码器的输入端,输入的后两位 通过一个 2-4 译码器的四个输入分别接入 4 片 2-4 译码器的低电平使能端即可image-20240117125311036
5-32 译码器74X139+74X1381 片 74X139+4 片 74X138使能端有效时。输入的前三位 分别接入四片 3-8 译码器的输入端,输入的后两位 通过 2-4 译码的 4 个结果分别接入四片 3-8 译码器的低电平使能端,从而决定是哪一个 3-8 译码器在工作image-20231208104231966

使用译码器实现逻辑函数

我们知道译码器的每一个输出代表一个最小项,那么对于一个 xx 变量的逻辑函数,可以通过以下步骤用 x2xx-2^x 译码器实现任意 xx 变量的逻辑函数

  1. 将逻辑函数转化为最小项表达式(大量使用摩根定律)
  2. 转化为译码器的输出(写成 mi\sum m_i 的形式)
  3. 在译码器的输出端加一个多输入与非门即可(对结果进行与非)

image-20231208110328594

数据分配器

功能:相当于多输出的单刀多掷开关,是将公共数据线上的数据按需要送到不同的通道上去的逻辑电路。

image-20231208114700981

图一:示意图

image-20231208114749275

图二:功能仿真图

4.4.3 数据选择器

名称型号逻辑符号功能分析逻辑图
2 选 1image-20240117135219030通过控制端 SS 来选择 D0,D1D_0,D_1image-20240117135236405
4 选 1image-20240117135258894通过控制端 S0,S1S_0,S_1 来选择 D0,D1,D2,D3D_0,D_1,D_2,D_3image-20240117135326247
8 选 174HC151image-20240117135401170通过控制端 S0S2S_0-S_2 来选择 D0D7D_0-D_7由于有现成的集成电路板,故就是逻辑符号
16 选 12 片 74HC151通过控制端 S0S3S_0-S_3 来选择 D0D15D_0-D_{15}输入的前三位 连接三个控制端,输入的最后一位 连接两片 74151 的使能端,其实就是译码器的魔改版,让输出为相应的译码结果的高电平而已image-20240117135555070

使用数据选择器实现逻辑函数

  • 变量个数 << 数据选择端个数:变量直接对应数据选择端,多余的选择端置 0,最后相应的信号输入端进行赋 1 或赋 0 的操作即可
  • 变量个数 == 数据选择端个数:本质上就是将逻辑函数转化为最小项表达式,然后与标准与或式进行比对,已出现的最小项与 1,未出现的最小项与 0,从而配凑产生了数据选择器最开始的式子。落到逻辑图上就是,数据选择端接入函数变量,信号输入端接入相应的高低电平,出现的最小项就输入 1,未出现的就输入 0 即可
  • 变量个数 >> 数据选择端个数:
    • 刚好多 1 个:变量 or 变量的非接入信号输入端
    • 不止多 1 个:同样采用将变量作为数据信号输入端,此外可能需要借助相关的门电路辅助进行

4.4.4 数值比较器

4.4.5 算术运算电路

半加器:即不考虑低位进位的一位二进制加法器。其中 SS 为输出位,CC 为进位,没有考虑低位的进位

{S=ABC=AB\begin{cases}S &=& A \oplus B \\C &=& AB\end{cases}

全加器:即考虑低位进位的一位二进制加法器。其中 SS 为输出位,CiC_i 为低位的进位,CoC_o 为进位

{S=ABCiCo=AB+(AB)Ci=AB+(A+B)Ci\begin{cases}S &=& A \oplus B \oplus C_i \\C_o &=& AB + (A \oplus B)C_i = AB+(A+B)C_i\end{cases}

5 锁存器和触发器

本章介绍时序逻辑电路的存储单元,分别为锁存器和触发器。其中锁存器对电平敏感,触发器对边沿敏感

5.1 基本双稳态电路

双稳态电路

5.2 SR 锁存器

门级元件组成电路图功能分析
或非门实现image-20231222100822028高电平有效。全 0 不变,谁 1 谁有效,都 1 不确定状态
与非门实现image-20231222100904959低电平有效。全 1 不变,谁 0 谁有效,都 0 不确定状态
应用电路图功能分析
开关电路image-20231222101855246image-20231222102251343无论开关如何震动,输出始终正常
门控 SR 锁存器image-20231222103758704就是加了一个使能端 E,如果 E 为 1,则就是一个基本的 SR 锁存器,如果 E 为 0,则保持

5.3 D 锁存器

电路名称逻辑电路图功能分析
传输门控制的 D 锁存器image-20231222105139215E = 0, Q = 不变;E = 1, Q = D
逻辑门控制的 D 锁存器image-20240117191849592E = 0, Q = 不变;E = 1, Q = D

5.4 触发器

5.4.1 主从 D 触发器的电路结构和工作原理

主从 D 触发器

5.4.2 典型的主从 D 触发器集成电路

逻辑图

电路板

真值表

5.5 触发器的逻辑功能

本目需要掌握有关触发器的 特性表特性方程状态图 三者的单独书写以及相互转化的逻辑过程,还需要掌握不同的触发器之间的相互 替换实现

类型逻辑符号特性表特性方程状态图
D 触发器image-20240117190140089image-20240117190521349image-20240117190538232image-20240117190553444
JK 触发器image-20240117190400613image-20240117190647444image-20240117190703599image-20240117190716843
T 触发器image-20240117190415482image-20240117190800382image-20240117190818921image-20240117190838344
T’ 触发器image-20240117190915612T1T\equiv 1image-20240117190926990
SR 触发器image-20240117190440059image-20240117191017989image-20240117191040611image-20240117191057024

6 时序逻辑电路

本部分只需要掌握同步时序逻辑电路的分析即可,具体直接从例题出发。三道同步时序逻辑电路分析的例题见教材 P282 ~ P286,分别为:

  • 例一:可控二进制计数器
  • 例二:可控双向二进制计数器
  • 例三:脉冲分配器

6.1 同步时序逻辑电路的分析

下面介绍同步时序逻辑电路分析的五个步骤。在分析之前我们要知道我们的最终目标是什么,可以知道,我们分析电路的最终目标是想要量化的确定电路的物理实现的功能,至于如何设计,此处不予讨论。现在给定了一个同步时序逻辑电路的 逻辑电路图,接下来我们应该:

  1. 了解电路组成:同步 or 异步?穆尔型输出(与输入无关) or 米利型输出(与输入有关) or 都有?由什么触发器组成的?触发器类型是上升沿出发 or 下降沿触发?

  2. 列出三个方程:

    • 输出方程:电路的最终输出

    • 激励方程:触发器的输入

    • 状态方程:触发器的输出(将触发器的输入也就是激励方程代入触发器的特性方程即可)

  3. 写出转换表(分析功能用)

  4. 写出状态图(分析功能用)

  5. 写出时序图(分析功能用)默认状态的初值设置为 0

6.2 计数器

本节讲讲 N 位二进制计数器中,利用集成电路板 74LVC16174LVC161 实现的 4 位同步二进制递增计数器。进而引出利用该 4 位计数器 实现模 N 计数器 的分析与设计思路。同时补充 74LVC16274LVC162 实现的 4 位同步十进制递增计时器,进而引出相关的模 N 设计思路。下面分析 74LVC161 4 位同步二进制递增计数器集成板

74LVC161 集成板

74LVC161 逻辑功能表

注解:

  • CR\overline{CR}异步清零。即无视时钟脉冲信号,直接清零
  • PE\overline{PE}同步预置。即当有效始终脉冲沿到来时,实现 4 个预置位的输出,即 D3,D2,D1,D0D_3,D_2,D_1,D_0
  • CEP,CETCEP,CET:使能端。同时为高电平电路才能正常工作
  • TCTC:进位输出

小结:

  • 多个集成板进行计数

  • 考虑如何通信:低位进位作为高位使能?

  • 考虑如何清零:同步(异步)清零?同步(异步)置数?

  • 学会利用 74LVC161 的“反馈清零法”实现模 N 计数器

  • 学会利用 74LVC161 的”反馈置数法“实现模 N 计数器

  • 学会实现 74LVC162 十进制递增功能功能(同步清零同步置数

  • 学会利用 74LVC162 的同步清零的特性实现模 9 的九进制计数器功能

  • 学会利用 74LVC162 实现模 24 的二十四进制计数器功能:

    00-09 与 10-19 的计数:通过低位片的进位端,作为高位片的使能端即可

    20-23 与 23-00 的计数:通过将低位片的两个低位与高位片通过 4023 三输入与非门连接起来,当全为 1 时,就是计数到 23 的状态,此时对高低片进行同步清零即可

]]>
@@ -1199,11 +1199,11 @@ - hexo-githubpages-domain - - /FrontEnd/Hexo/hexo-githubpages-domain/ + hexo-migrate + + /FrontEnd/Hexo/hexo-migrate/ - 在 GitHub Pages 中使用自己的域名

如果你觉得使用 [username].github.io 域名过于 ugly,你可以使用自己的域名!

前置条件

  • 购买一个自己的域名:可以在阿里云、腾讯云等大云服务厂商购买域名。价位高低均有,适合自己的即可
  • 使用 GitHub Pages 部署静态站点

开始操作

我们知道,对于域名绑定的服务,如果被绑定的主机位于国内,则需要备案且步骤较为繁琐,但是 GitHub Pages 的主机显然都在米国,那就不需要备案了。我们以阿里云购买的域名为例进行操作

第一步

将自己购买的域名绑定到 [username].github.io,这一步是为了在别人访问 [username].github.io 重定向到 自己购买的域名。

进入域名控制台并进入解析界面:

进入域名控制台并进入解析界面

配置以下两条解析记录:其中 www.[domain].[xxx] 可以实现自动重定向到 [domain].[xxx],也可以不解析该记录。

配置解析记录

注意此处的解析请求来源一定要填 默认 !不要选择境外,否则无法解析成功

此处的解析请求来源一定要填默认

第二步

在 GitHub Pages 界面绑定刚才购买的域名。

进入仓库中的 setting > pages 页面,在 Custom domain 中填入 [domain].[xxx] 顶级域名并勾选强制 https 访问服务

在 Custom domain 中填入顶级域名

等待几分钟 DNS 解析即可使用 [domain].[xxx] or www.[domain].[xxx] or [username].github.io 访问自己的静态网站啦!

第三步

这一步其实是为了解决一个 bug,如果你在操作完上述两步以后,会发现静态页面仓库内多了一个 CNAME 文件。这是因为自定义域名以后,GitHub Pages 服务需要知道将请求路由到哪个域名上,而这需要从 CNAME 文件中获取信息

仓库内多了一个 CNAME 文件

但是如果后期更新站点时,由于 Hexo 强制覆盖分支的特性,会自动覆盖掉这个 CNAME 文件,从而无法正确的路由请求,也就会出现 404 的错误。解决方案就是在 hexo 的 source/ 文件夹下添加一个 CNAME 文件。这样该 CNAME 文件就会被 hexo 识别为自己的内容,从而每次都可以部署到相应的分支上了

在 hexo 的 source/ 文件夹下添加一个 CNAME 文件

ENDEND

]]>
+ Hexo 迁移指南

前言

为了改变本地项目名称(强迫症),同时为了练习迁移编辑环境,特此记录。

一、克隆仓库

1
git clone https://github.com/Explorer-Dong/explorer-dong.github.io.git HexoBlog

二、安装依赖

1
npm install

三、编辑推送

1
2
3
git add .
git commit -m 'add: xxx'
git push -u origin main

四、个性化配置

修改本地的远程仓库名称

1
git remote rename origin github

增加同步 push 备份仓库

1
git remote set-url --add github https://gitee.com/idwj/idwj.git

修改推送分支源

1
git push -u github main

缺点

修改编辑路径以后,会导致所有的文章的编写日期刷新为当前时间

]]>
@@ -1220,11 +1220,11 @@ - hexo-learning-record - - /FrontEnd/Hexo/hexo-learning-record/ + hexo-githubpages-domain + + /FrontEnd/Hexo/hexo-githubpages-domain/ - Hexo 学习记录

前言

操作系统:Windows 11 OS

建站第一枪,使用 Hexo 静态站点生成器搭建自己的博客网站。首先解释一下什么叫静态站点生成器,其实就是将自己写好的 Markdown 文件自动构建为 HTML 文件,然后进行浏览器渲染,而不需要自己编写前端样式文件。在开始介绍 Hexo 建站步骤之前,最好需要掌握一下技能(不掌握也没事,边建边学也行,我就是这样,不过可能会比较浪费时间):

  • git 与 github:进行项目版本管理,并且在后期自动化打包部署上有很大的用
  • nodejs 与 npm:可以将其粗略的类比为 python 与 pip 的关系与作用
  • html、css、javascrip:大概看得懂会 Ctrl C+V 即可,可以后期修改主题样式

为了防止某些同学还没开始就结束,先补充一下 nodejs 与 npm 的说明与使用、避坑指南,本地已经有可运行 node 环境的同学可以直接跳到 #步骤一:创建本地环境。下面开始介绍:

概念介绍:

  • nodejs:就是 JavaScript 语言的运行环境,可以将其粗略的类比于 python 解释器(实质上功能远不止解释器),你需要将其下载到本地,推荐长期支持版(LTS)。

  • npm:npm 是 JavaScript 第三方包的管理工具,可以将其粗略的类比于 pip 包管理工具。本篇博客使用的 Hexo 其实就是一个 JavaScrip 第三方包。在下载 nodejs 时,npm 就会自动打包一起下载在同一个目录下。

注意点:

  • 第一个就是需要将下载解压后的 nodejs 文件夹的权限设置为最高,即所有的权限全部赋予。防止后期出现安装第三方包失败的情况(因为需要对该文件夹中的内容进行读写)
  • 第二个就是需要设定环境变量,不光设定 npm 的环境变量,还需要设定第三方包的环境变量。前者是为了使用 npm 命令行工具,后者是为了使用第三方包携带的自己的命令行工具,比如此处涉及到的 hexo 命令行工具
  • 第三个就是需要设定 npm 的代理。因为 node 的第三方库下载地址墙的严重,不设置代理会很慢。具体设定语法自行搜索

下面开始正式介绍基于 hexo 搭建个人博客网站的步骤

一、创建本地环境

就像一个项目一样,一个 hexo 网站也就是一个项目。接下来进入该项目后,打开命令行工具进行操作。目前主流的几乎都可以:bash、cmd、当然由于本地环境时 Windows OS,故对于 Linux、MacOS 的用户就不用说了。

  • 创建 git 仓库进行版本管理

    1
    git init
  • 安装 JavaScrip 第三方包 Hexo

    1
    npm install -g hexo-cli
  • 检查是否安装成功

    1
    hexo -v
  • 初始化一个静态站点并刷新

    1
    2
    hexo init
    npm install

二、本地运行测试

  • 生成静态文件,可简写为 hexo g -d(就是最终部署的文件,会打包在一个叫 public 的文件夹中)不要少 -d 参数

    1
    hexo generate -d
  • 启动本地服务,可简写为 hexo s

    1
    hexo server

hexo 服务默认启动 4000 端口,现在浏览器访问 localhost:4000 即可看到 hexo 的模板博客网站

三、手动部署云端

现在我们需要将其部署到 [username].github.io 上。一般博客该路径进行部署,如果想要展示其他的网站 demo,一般路径设置为 [username].github.io/[repo]。

  • 安装 hexo 部署插件 hexo-deployer-git,也是 nodejs 第三方包(一定不要漏掉 --save 参数,否则卸载重装该包)

    1
    npm install -g hexo-deployer-git --save
  • 创建一个 github 仓库,仓库名就设置为 [username].github.io,然后在本地进行远程绑定,即

    1
    git remote add origin <https link or ssh link>
  • 如果没有执行生成静态文件的命令,需要执行一下(一定不要漏掉 -d 参数,否则重新执行)

    1
    hexo generate -d
  • 执行部署命令,下面两条均可

    1
    npm run deploy
    1
    hexo deploy

现在访问 https://[username].github.io 如果可以看到与之前本地测试一样的界面的,恭喜你,现在已经可以进行快乐的博客编写并且链接分享进行技术交流~~(装b)~~了

四、自动部署云端

这一步个人认为属于锦上添花的一步。其实进行自动化部署相比手动部署只少了两步,分别为生成静态文件 hexo generate -d 与部署 hexo deploy 的步骤。那么我们为什么还要弄自动化部署呢?愚以为:是为了源文件备份。我们知道,当前仓库里的博客文件格式都是 .html 但是我们博客的源码都是 .md,前者不便于修改,后者十分便于修改。如果本地数据丢失,那么对于之前写的博客就丢失了所有的 md 源码。但是自动化部署的逻辑就可以规避掉这个问题,可以实现 md 文件的云端备份,当然也可以自动化部署啦~。下面介绍一下如何使用 Github Action 进行自动化部署的逻辑:

其实就是将本地源码修改完后 push 到该仓库的另一个分支下,然后 github 自带的 Github Action 会监听到该仓库的 push 操作,然后进行工作流脚本文件的执行,实现在本地部署的一系列操作。是不是很简单的逻辑!写到这其实可以发现,自动化部署还有一个好处就是:博客编写者只需要熟悉 git 的相关指令与逻辑即可,不需要关心 hexo 的指令,提高了编写的效率。接下来进行详细步骤的讲解:

  • 创建工作流文件。在根目录下创建以下文件关系:

    1
    touch .github/workflows/deploy.yml
  • 编辑 deploy.yml 文件。模板如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    name: Build and Deploy
    on: [push]
    jobs:
    build-and-deploy:
    runs-on: ubuntu-latest
    steps:
    # 相当于 git clone 到服务器
    - name: Checkout 🛎️
    uses: actions/checkout@v3
    with:
    persist-credentials: false

    # 安装依赖并生成页面
    - name: Install and Build 🔧
    run: |
    npm install
    npm run build
    env:
    CI: false

    # 部署
    - name: Deploy 🚀
    uses: JamesIves/github-pages-deploy-action@v4
    with:
    GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
    BRANCH: main # 修改为你的静态文件分支
    FOLDER: public # 修改为你的静态文件生成存放的文件夹
  • 将本地相关文件进行 git 管理后,创建分支(假设叫 auto-deploy)

    1
    git branch auto-deploy

push 到仓库,等待 github 自动部署后,重新加载 https://[username].github.io 就可以发现站点内容已经更新了!

参考

搭建:Luke教你20分钟快速搭建个人博客系列(hexo篇)

个性化定制:GitHub Pages + Hexo搭建个人博客网站,史上最全教程

原理:npm install是什么意思

]]>
+ 在 GitHub Pages 中使用自己的域名

如果你觉得使用 [username].github.io 域名过于 ugly,你可以使用自己的域名!

前置条件

  • 购买一个自己的域名:可以在阿里云、腾讯云等大云服务厂商购买域名。价位高低均有,适合自己的即可
  • 使用 GitHub Pages 部署静态站点

开始操作

我们知道,对于域名绑定的服务,如果被绑定的主机位于国内,则需要备案且步骤较为繁琐,但是 GitHub Pages 的主机显然都在米国,那就不需要备案了。我们以阿里云购买的域名为例进行操作

第一步

将自己购买的域名绑定到 [username].github.io,这一步是为了在别人访问 [username].github.io 重定向到 自己购买的域名。

进入域名控制台并进入解析界面:

进入域名控制台并进入解析界面

配置以下两条解析记录:其中 www.[domain].[xxx] 可以实现自动重定向到 [domain].[xxx],也可以不解析该记录。

配置解析记录

注意此处的解析请求来源一定要填 默认 !不要选择境外,否则无法解析成功

此处的解析请求来源一定要填默认

第二步

在 GitHub Pages 界面绑定刚才购买的域名。

进入仓库中的 setting > pages 页面,在 Custom domain 中填入 [domain].[xxx] 顶级域名并勾选强制 https 访问服务

在 Custom domain 中填入顶级域名

等待几分钟 DNS 解析即可使用 [domain].[xxx] or www.[domain].[xxx] or [username].github.io 访问自己的静态网站啦!

第三步

这一步其实是为了解决一个 bug,如果你在操作完上述两步以后,会发现静态页面仓库内多了一个 CNAME 文件。这是因为自定义域名以后,GitHub Pages 服务需要知道将请求路由到哪个域名上,而这需要从 CNAME 文件中获取信息

仓库内多了一个 CNAME 文件

但是如果后期更新站点时,由于 Hexo 强制覆盖分支的特性,会自动覆盖掉这个 CNAME 文件,从而无法正确的路由请求,也就会出现 404 的错误。解决方案就是在 hexo 的 source/ 文件夹下添加一个 CNAME 文件。这样该 CNAME 文件就会被 hexo 识别为自己的内容,从而每次都可以部署到相应的分支上了

在 hexo 的 source/ 文件夹下添加一个 CNAME 文件

ENDEND

]]>
@@ -1241,11 +1241,11 @@ - hexo-migrate - - /FrontEnd/Hexo/hexo-migrate/ + hexo-learning-record + + /FrontEnd/Hexo/hexo-learning-record/ - Hexo 迁移指南

前言

为了改变本地项目名称(强迫症),同时为了练习迁移编辑环境,特此记录。

一、克隆仓库

1
git clone https://github.com/Explorer-Dong/explorer-dong.github.io.git HexoBlog

二、安装依赖

1
npm install

三、编辑推送

1
2
3
git add .
git commit -m 'add: xxx'
git push -u origin main

四、个性化配置

修改本地的远程仓库名称

1
git remote rename origin github

增加同步 push 备份仓库

1
git remote set-url --add github https://gitee.com/idwj/idwj.git

修改推送分支源

1
git push -u github main

缺点

修改编辑路径以后,会导致所有的文章的编写日期刷新为当前时间

]]>
+ Hexo 学习记录

前言

操作系统:Windows 11 OS

建站第一枪,使用 Hexo 静态站点生成器搭建自己的博客网站。首先解释一下什么叫静态站点生成器,其实就是将自己写好的 Markdown 文件自动构建为 HTML 文件,然后进行浏览器渲染,而不需要自己编写前端样式文件。在开始介绍 Hexo 建站步骤之前,最好需要掌握一下技能(不掌握也没事,边建边学也行,我就是这样,不过可能会比较浪费时间):

  • git 与 github:进行项目版本管理,并且在后期自动化打包部署上有很大的用
  • nodejs 与 npm:可以将其粗略的类比为 python 与 pip 的关系与作用
  • html、css、javascrip:大概看得懂会 Ctrl C+V 即可,可以后期修改主题样式

为了防止某些同学还没开始就结束,先补充一下 nodejs 与 npm 的说明与使用、避坑指南,本地已经有可运行 node 环境的同学可以直接跳到 #步骤一:创建本地环境。下面开始介绍:

概念介绍:

  • nodejs:就是 JavaScript 语言的运行环境,可以将其粗略的类比于 python 解释器(实质上功能远不止解释器),你需要将其下载到本地,推荐长期支持版(LTS)。

  • npm:npm 是 JavaScript 第三方包的管理工具,可以将其粗略的类比于 pip 包管理工具。本篇博客使用的 Hexo 其实就是一个 JavaScrip 第三方包。在下载 nodejs 时,npm 就会自动打包一起下载在同一个目录下。

注意点:

  • 第一个就是需要将下载解压后的 nodejs 文件夹的权限设置为最高,即所有的权限全部赋予。防止后期出现安装第三方包失败的情况(因为需要对该文件夹中的内容进行读写)
  • 第二个就是需要设定环境变量,不光设定 npm 的环境变量,还需要设定第三方包的环境变量。前者是为了使用 npm 命令行工具,后者是为了使用第三方包携带的自己的命令行工具,比如此处涉及到的 hexo 命令行工具
  • 第三个就是需要设定 npm 的代理。因为 node 的第三方库下载地址墙的严重,不设置代理会很慢。具体设定语法自行搜索

下面开始正式介绍基于 hexo 搭建个人博客网站的步骤

一、创建本地环境

就像一个项目一样,一个 hexo 网站也就是一个项目。接下来进入该项目后,打开命令行工具进行操作。目前主流的几乎都可以:bash、cmd、当然由于本地环境时 Windows OS,故对于 Linux、MacOS 的用户就不用说了。

  • 创建 git 仓库进行版本管理

    1
    git init
  • 安装 JavaScrip 第三方包 Hexo

    1
    npm install -g hexo-cli
  • 检查是否安装成功

    1
    hexo -v
  • 初始化一个静态站点并刷新

    1
    2
    hexo init
    npm install

二、本地运行测试

  • 生成静态文件,可简写为 hexo g -d(就是最终部署的文件,会打包在一个叫 public 的文件夹中)不要少 -d 参数

    1
    hexo generate -d
  • 启动本地服务,可简写为 hexo s

    1
    hexo server

hexo 服务默认启动 4000 端口,现在浏览器访问 localhost:4000 即可看到 hexo 的模板博客网站

三、手动部署云端

现在我们需要将其部署到 [username].github.io 上。一般博客该路径进行部署,如果想要展示其他的网站 demo,一般路径设置为 [username].github.io/[repo]。

  • 安装 hexo 部署插件 hexo-deployer-git,也是 nodejs 第三方包(一定不要漏掉 --save 参数,否则卸载重装该包)

    1
    npm install -g hexo-deployer-git --save
  • 创建一个 github 仓库,仓库名就设置为 [username].github.io,然后在本地进行远程绑定,即

    1
    git remote add origin <https link or ssh link>
  • 如果没有执行生成静态文件的命令,需要执行一下(一定不要漏掉 -d 参数,否则重新执行)

    1
    hexo generate -d
  • 执行部署命令,下面两条均可

    1
    npm run deploy
    1
    hexo deploy

现在访问 https://[username].github.io 如果可以看到与之前本地测试一样的界面的,恭喜你,现在已经可以进行快乐的博客编写并且链接分享进行技术交流~~(装b)~~了

四、自动部署云端

这一步个人认为属于锦上添花的一步。其实进行自动化部署相比手动部署只少了两步,分别为生成静态文件 hexo generate -d 与部署 hexo deploy 的步骤。那么我们为什么还要弄自动化部署呢?愚以为:是为了源文件备份。我们知道,当前仓库里的博客文件格式都是 .html 但是我们博客的源码都是 .md,前者不便于修改,后者十分便于修改。如果本地数据丢失,那么对于之前写的博客就丢失了所有的 md 源码。但是自动化部署的逻辑就可以规避掉这个问题,可以实现 md 文件的云端备份,当然也可以自动化部署啦~。下面介绍一下如何使用 Github Action 进行自动化部署的逻辑:

其实就是将本地源码修改完后 push 到该仓库的另一个分支下,然后 github 自带的 Github Action 会监听到该仓库的 push 操作,然后进行工作流脚本文件的执行,实现在本地部署的一系列操作。是不是很简单的逻辑!写到这其实可以发现,自动化部署还有一个好处就是:博客编写者只需要熟悉 git 的相关指令与逻辑即可,不需要关心 hexo 的指令,提高了编写的效率。接下来进行详细步骤的讲解:

  • 创建工作流文件。在根目录下创建以下文件关系:

    1
    touch .github/workflows/deploy.yml
  • 编辑 deploy.yml 文件。模板如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    name: Build and Deploy
    on: [push]
    jobs:
    build-and-deploy:
    runs-on: ubuntu-latest
    steps:
    # 相当于 git clone 到服务器
    - name: Checkout 🛎️
    uses: actions/checkout@v3
    with:
    persist-credentials: false

    # 安装依赖并生成页面
    - name: Install and Build 🔧
    run: |
    npm install
    npm run build
    env:
    CI: false

    # 部署
    - name: Deploy 🚀
    uses: JamesIves/github-pages-deploy-action@v4
    with:
    GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
    BRANCH: main # 修改为你的静态文件分支
    FOLDER: public # 修改为你的静态文件生成存放的文件夹
  • 将本地相关文件进行 git 管理后,创建分支(假设叫 auto-deploy)

    1
    git branch auto-deploy

push 到仓库,等待 github 自动部署后,重新加载 https://[username].github.io 就可以发现站点内容已经更新了!

参考

搭建:Luke教你20分钟快速搭建个人博客系列(hexo篇)

个性化定制:GitHub Pages + Hexo搭建个人博客网站,史上最全教程

原理:npm install是什么意思

]]>
@@ -1281,11 +1281,11 @@ - git-self-define-command - - /DevTools/Git/git-self-define-command/ + git-basic + + /DevTools/Git/git-basic/ - Git 自定义命令

前言

在使用 hexo 搭建个人博客时, 共两种部署的方法. 分别为:

  • 本地利用 hexo 的插件 hexo-deployer-git 来实现部署, 缺点是需要多敲几个命令行且不方便对源码进行云端备份
  • 使用 Github Action 的 workflow 自动化部署, 优势就是可以在 push 备份源码的同时自动检测行为并自动构建部署代码

看似十分方便但是问题也出在这, 我发现使用 Github Action 的 workflow 自动化部署 的站点内容与使用 hexo 插件部署的内容不一致. 由于不懂 yml 的构建语法与逻辑以及关于 git 用户授权等知识, 以至于我始终无法 debug 问题到底出在哪. 最终决定放弃使用 Github Action, 还是老老实实敲 hexo 的命令行, 但是我还需要进行笔记的源码备份, 故还需要在 push 到云端.

为了尽可能的简化命令行, 我尝试进行 git 命令的宏定义. 查询一番后发现确实可以, 故有了本篇博客, 下面开始介绍如何进行 git 的命令的宏定义:

宏定义

操作系统为 Windows OS 11

我们进入 Git 软件的安装目录, 我的是 D:\installation_package\Git, 然后进入 etc\profile.d 并编辑 aliases.sh 文件, 默认内容为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# Some good standards, which are not used if the user
# creates his/her own .bashrc/.bash_profile

# --show-control-chars: help showing Korean or accented characters
alias ls='ls -F --color=auto --show-control-chars'
alias ll='ls -l'

case "$TERM" in
xterm*)
# The following programs are known to require a Win32 Console
# for interactive usage, therefore let's launch them through winpty
# when run inside `mintty`.
for name in node ipython php php5 psql python2.7 winget
do
case "$(type -p "$name".exe 2>/dev/null)" in
''|/usr/bin/*) continue;;
esac
alias $name="winpty $name.exe"
done
;;
esac

我们向 alias 中添加一行自定义的宏定义: alias gph='git push && hexo clean && hexo g && hexo d', 就可以实现在 push 的同时使用 hexo 的部署插件进行部署了!

]]>
+ Git 基础

前言

Git 是一款版本管理软件, 适用目前绝大多数操作系统; Github 是一个代码托管平台, 与 Git 没有任何关系. 只不过 Git 可以基于 Github 进行分布式云存储与交互, 因此往往需要结合二者从而达到相对良好的 Teamwork 状态. 本文是我基于 Git 的版本管理学习记录, 涉及到的指令只是冰山一角, 但是使用频率较高. 详细的指令请跳转至官方教学: https://git-scm.com/book/zh/v2

全文分为两个部分, 分别为 Git 版本管理的架构 Architecture 与 Git 的命令 command. 其中 Architecture 使用 Xmind 绘制, command 采用 Git Bash 模拟 Unix 命令行终端. 本地 OS 为 Microsoft Windows 11.

Architecture

工作区暂存区仓库区

git architecture

Command

零、查看

0.1 查看状态

1
2
# 查看当前文件状态
git status

0.2 查看日志

1
2
# 从当前版本开始查询 commit 日志
git log
1
2
# 查看所有 commit 日志
git reflog

0.3 查看差异

1
2
3
4
5
# 查看工作区与暂存区的差异
git diff <FileName>

# 查看两个区域所有文件的差异
git diff
1
2
3
4
5
# 查看暂存区与仓库区的差异
git diff --cached <FileName>

# 查看两个区域所有文件的差异
git diff --cached

一、配置

1.1 初始化

1
2
# 初始化仓库
git init

1.2 查看配置

1
2
# 查看 git 配置信息
git config --list
1
2
3
4
# 查看 git 用户名、密码、邮箱的配置
git config user.name
git config user.password
git config user.email
1
2
3
# 查看代理
git config --global --get http.proxy
git config --global --get https.proxy

1.3 编辑配置

邮箱、密码、用户名
1
2
3
4
5
6
7
8
9
# 配置(修改) Email & Pwd & Username (局部)
git config user.name "xxx"
git config user.password "xxx"
git config user.email "xxx@xxx.com"

# 配置(修改) Email & Pwd & Username (全局)
git config --global user.name xxx
git config --global user.password xxx
git config --global user.email "xxx@xxx.com"
VPN
1
2
3
4
5
6
7
# 配置代理
git config --global http.proxy 127.0.0.1:<VpnPort>
git config --global https.proxy 127.0.0.1:<VpnPort>

# 取消代理
git config --global --unset http.proxy
git config --global --unset https.proxy
远程
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 连接远程服务器
git remote add <RemoteName> https://github.com/用户名/仓库名.git

# 查看所有连接的远程
git remote -v

# 修改远程别名
git remote rename <OldRemoteName> <NewRemoteName>

# 修改远程 URL
git remote set-url <RemoteName> <NewURL>

# 增加远程 push 的仓库
git remote set-url --add github https://gitee.com/idwj/idwj.git

# 删除远程
git remote rm <RemoteName>

二、迭代

2.1 工作区到暂存区

1
2
3
4
5
# 工作区到暂存区(单文件)
git add <FileName>

# 工作区到暂存区(全部变动文件)
git add .

2.2 暂存区到仓库区

1
2
# 暂存区到仓库区
git commit -m '<Comment>'

2.3 仓库区到服务器

1
2
3
4
5
6
7
8
# 仓库区到云服务器(常规方法)
git push <RemoteName> <BranchName>

# 仓库区到云服务器(初始配置仓库推送默认地址)
git push -u <RemoteName> <BranchName>

# 仓库区到云服务器(已配置默认推送地址后)
git push
1
2
# 强制覆盖推送
git push --force <RemoteName> <BranchName>

2.4 服务器到本地

一键克隆整个项目

1
2
# 从服务器克隆仓库
git clone https://github.com/<UserName>/<ProjectName>.git <ProjectName>

远程更新, 本地未更新(方法一)

1
2
3
4
5
# 抓取复制远程代码
git fetch <RemoteName> <BranchName>

# 更新本地分支
git merge <BranchName>

远程更新, 本地未更新(方法二)

1
2
# 直接使用 pull 命令, 等价于上述方法一的两步, 即先抓取, 后合并分支
git pull

远程更新, 本地也更新

1
2
3
# 在将远程代码与本地合并后, 再将个人修改的代码推送到远程
git pull
git push

三、回溯

3.1 工作区到未管理或上一个版本

1
2
# 取消新文件的管理 or 将修改文件回溯到上一个版本的初始状态
git checkout -- <FileName>

3.2 暂存区到工作区状态

1
2
3
4
5
# 取消 add(一个文件), 默认为 --mixed 模式, 即保存修改但是从暂存区到工作区
git reset <FileName>

# 取消 add(全部文件), 默认为 --mixed 模式, 即保存修改但是从暂存区到工作区
git reset .

3.3 仓库区到暂存区状态

1
2
3
4
5
6
7
# 移动 HEAD 指针到指定的版本
git reset '<commit_id>' # 默认为 --mixed, 将指定版本与暂存区全部合并到工作区, 暂存区清空
git reset --soft '<commit_id>' # 【更推荐】工作区不变, 只是将指定版本合并到暂存区
git reset --hard '<commit_id>' # 【不推荐】工作区与暂存区全部被指定版本覆盖

# 取消上一次 comment 并进入 vim 编辑模式
git commit --amend

3.4 取消服务器的修改

取消当前版本某文件(夹)的版本管理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 希望某些文件取消版本管理, 但是依然保留在工作区
git rm --cached <FileName>
git commit -m 'remove xxx file'
git push
在 .gitignore 中增加上述 <FileName>

# 希望某些文件取消版本管理, 同时在不保留在工作区
git rm <FileName>
git commit -m 'delete xxx file(folder)'
git push

# 如果希望某个目录离开暂存区可以添加 -r 参数, 从而可以递归的将该目录下所有的文件 & 子目录全部取消版本管理
git rm -r --cached <Folder>
git commit -m 'remove xxx floder'
git push

取消所有版本某文件的版本管理

1
2
3
4
git filter-branch --force --index-filter 'git rm --cached --ignore-unmatch <FilePath>' --prune-empty --tag-name-filter cat -- --all

# 参考
https://blog.csdn.net/q258523454/article/details/83899911

希望某些文件加入版本管理

1
删除在 .gitignore 中的相应语句即可

四、分支

4.1 创建分支

1
2
3
4
5
# 创建分支
git branch <BranchName>

# 远程同步
git push <RemoteName> <BranchName>

4.2 删除分支

1
2
3
4
5
6
# 切换到另一个分支再进行删除操作
git switch <AnotherBranchName>
git branch -d <BranchName>

# 远程同步
git push <RemoteName> --delete <BranchName>

4.3 修改分支

如果修改的分支为远程保护分支, 则在远程更新之前, 需要在远程相应的服务商家那里对保护分支进行重新设定

1
2
3
4
5
6
# 修改名称
git branch -m <OldName> <NewName>

# 远程同步
git push <RemoteName> <NewName>
git push <RemoteName> --delete <OldName>

4.4 合并分支

1
2
# 首先将当前分支切换到需要被合并的分支 <NowBranch>, 接着合并需要被合并的分支 <TodoBranch>
git merge <TodoBranch>

4.5 拉取分支

1
2
# 在 clone 后只会拉取默认分支, 想要拉取其余的分支执行
git checkout -t <RemoteName>/<BranchName>
]]>
@@ -1302,11 +1302,11 @@ - git-basic - - /DevTools/Git/git-basic/ + git-self-define-command + + /DevTools/Git/git-self-define-command/ - Git 基础

前言

Git 是一款版本管理软件, 适用目前绝大多数操作系统; Github 是一个代码托管平台, 与 Git 没有任何关系. 只不过 Git 可以基于 Github 进行分布式云存储与交互, 因此往往需要结合二者从而达到相对良好的 Teamwork 状态. 本文是我基于 Git 的版本管理学习记录, 涉及到的指令只是冰山一角, 但是使用频率较高. 详细的指令请跳转至官方教学: https://git-scm.com/book/zh/v2

全文分为两个部分, 分别为 Git 版本管理的架构 Architecture 与 Git 的命令 command. 其中 Architecture 使用 Xmind 绘制, command 采用 Git Bash 模拟 Unix 命令行终端. 本地 OS 为 Microsoft Windows 11.

Architecture

工作区暂存区仓库区

git architecture

Command

零、查看

0.1 查看状态

1
2
# 查看当前文件状态
git status

0.2 查看日志

1
2
# 从当前版本开始查询 commit 日志
git log
1
2
# 查看所有 commit 日志
git reflog

0.3 查看差异

1
2
3
4
5
# 查看工作区与暂存区的差异
git diff <FileName>

# 查看两个区域所有文件的差异
git diff
1
2
3
4
5
# 查看暂存区与仓库区的差异
git diff --cached <FileName>

# 查看两个区域所有文件的差异
git diff --cached

一、配置

1.1 初始化

1
2
# 初始化仓库
git init

1.2 查看配置

1
2
# 查看 git 配置信息
git config --list
1
2
3
4
# 查看 git 用户名、密码、邮箱的配置
git config user.name
git config user.password
git config user.email
1
2
3
# 查看代理
git config --global --get http.proxy
git config --global --get https.proxy

1.3 编辑配置

邮箱、密码、用户名
1
2
3
4
5
6
7
8
9
# 配置(修改) Email & Pwd & Username (局部)
git config user.name "xxx"
git config user.password "xxx"
git config user.email "xxx@xxx.com"

# 配置(修改) Email & Pwd & Username (全局)
git config --global user.name xxx
git config --global user.password xxx
git config --global user.email "xxx@xxx.com"
VPN
1
2
3
4
5
6
7
# 配置代理
git config --global http.proxy 127.0.0.1:<VpnPort>
git config --global https.proxy 127.0.0.1:<VpnPort>

# 取消代理
git config --global --unset http.proxy
git config --global --unset https.proxy
远程
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 连接远程服务器
git remote add <RemoteName> https://github.com/用户名/仓库名.git

# 查看所有连接的远程
git remote -v

# 修改远程别名
git remote rename <OldRemoteName> <NewRemoteName>

# 修改远程 URL
git remote set-url <RemoteName> <NewURL>

# 增加远程 push 的仓库
git remote set-url --add github https://gitee.com/idwj/idwj.git

# 删除远程
git remote rm <RemoteName>

二、迭代

2.1 工作区到暂存区

1
2
3
4
5
# 工作区到暂存区(单文件)
git add <FileName>

# 工作区到暂存区(全部变动文件)
git add .

2.2 暂存区到仓库区

1
2
# 暂存区到仓库区
git commit -m '<Comment>'

2.3 仓库区到服务器

1
2
3
4
5
6
7
8
# 仓库区到云服务器(常规方法)
git push <RemoteName> <BranchName>

# 仓库区到云服务器(初始配置仓库推送默认地址)
git push -u <RemoteName> <BranchName>

# 仓库区到云服务器(已配置默认推送地址后)
git push
1
2
# 强制覆盖推送
git push --force <RemoteName> <BranchName>

2.4 服务器到本地

一键克隆整个项目

1
2
# 从服务器克隆仓库
git clone https://github.com/<UserName>/<ProjectName>.git <ProjectName>

远程更新, 本地未更新(方法一)

1
2
3
4
5
# 抓取复制远程代码
git fetch <RemoteName> <BranchName>

# 更新本地分支
git merge <BranchName>

远程更新, 本地未更新(方法二)

1
2
# 直接使用 pull 命令, 等价于上述方法一的两步, 即先抓取, 后合并分支
git pull

远程更新, 本地也更新

1
2
3
# 在将远程代码与本地合并后, 再将个人修改的代码推送到远程
git pull
git push

三、回溯

3.1 工作区到未管理或上一个版本

1
2
# 取消新文件的管理 or 将修改文件回溯到上一个版本的初始状态
git checkout -- <FileName>

3.2 暂存区到工作区状态

1
2
3
4
5
# 取消 add(一个文件), 默认为 --mixed 模式, 即保存修改但是从暂存区到工作区
git reset <FileName>

# 取消 add(全部文件), 默认为 --mixed 模式, 即保存修改但是从暂存区到工作区
git reset .

3.3 仓库区到暂存区状态

1
2
3
4
5
6
7
# 移动 HEAD 指针到指定的版本
git reset '<commit_id>' # 默认为 --mixed, 将指定版本与暂存区全部合并到工作区, 暂存区清空
git reset --soft '<commit_id>' # 【更推荐】工作区不变, 只是将指定版本合并到暂存区
git reset --hard '<commit_id>' # 【不推荐】工作区与暂存区全部被指定版本覆盖

# 取消上一次 comment 并进入 vim 编辑模式
git commit --amend

3.4 取消服务器的修改

取消当前版本某文件(夹)的版本管理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 希望某些文件取消版本管理, 但是依然保留在工作区
git rm --cached <FileName>
git commit -m 'remove xxx file'
git push
在 .gitignore 中增加上述 <FileName>

# 希望某些文件取消版本管理, 同时在不保留在工作区
git rm <FileName>
git commit -m 'delete xxx file(folder)'
git push

# 如果希望某个目录离开暂存区可以添加 -r 参数, 从而可以递归的将该目录下所有的文件 & 子目录全部取消版本管理
git rm -r --cached <Folder>
git commit -m 'remove xxx floder'
git push

取消所有版本某文件的版本管理

1
2
3
4
git filter-branch --force --index-filter 'git rm --cached --ignore-unmatch <FilePath>' --prune-empty --tag-name-filter cat -- --all

# 参考
https://blog.csdn.net/q258523454/article/details/83899911

希望某些文件加入版本管理

1
删除在 .gitignore 中的相应语句即可

四、分支

4.1 创建分支

1
2
3
4
5
# 创建分支
git branch <BranchName>

# 远程同步
git push <RemoteName> <BranchName>

4.2 删除分支

1
2
3
4
5
6
# 切换到另一个分支再进行删除操作
git switch <AnotherBranchName>
git branch -d <BranchName>

# 远程同步
git push <RemoteName> --delete <BranchName>

4.3 修改分支

如果修改的分支为远程保护分支, 则在远程更新之前, 需要在远程相应的服务商家那里对保护分支进行重新设定

1
2
3
4
5
6
# 修改名称
git branch -m <OldName> <NewName>

# 远程同步
git push <RemoteName> <NewName>
git push <RemoteName> --delete <OldName>

4.4 合并分支

1
2
# 首先将当前分支切换到需要被合并的分支 <NowBranch>, 接着合并需要被合并的分支 <TodoBranch>
git merge <TodoBranch>

4.5 拉取分支

1
2
# 在 clone 后只会拉取默认分支, 想要拉取其余的分支执行
git checkout -t <RemoteName>/<BranchName>
]]>
+ Git 自定义命令

前言

在使用 hexo 搭建个人博客时, 共两种部署的方法. 分别为:

  • 本地利用 hexo 的插件 hexo-deployer-git 来实现部署, 缺点是需要多敲几个命令行且不方便对源码进行云端备份
  • 使用 Github Action 的 workflow 自动化部署, 优势就是可以在 push 备份源码的同时自动检测行为并自动构建部署代码

看似十分方便但是问题也出在这, 我发现使用 Github Action 的 workflow 自动化部署 的站点内容与使用 hexo 插件部署的内容不一致. 由于不懂 yml 的构建语法与逻辑以及关于 git 用户授权等知识, 以至于我始终无法 debug 问题到底出在哪. 最终决定放弃使用 Github Action, 还是老老实实敲 hexo 的命令行, 但是我还需要进行笔记的源码备份, 故还需要在 push 到云端.

为了尽可能的简化命令行, 我尝试进行 git 命令的宏定义. 查询一番后发现确实可以, 故有了本篇博客, 下面开始介绍如何进行 git 的命令的宏定义:

宏定义

操作系统为 Windows OS 11

我们进入 Git 软件的安装目录, 我的是 D:\installation_package\Git, 然后进入 etc\profile.d 并编辑 aliases.sh 文件, 默认内容为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# Some good standards, which are not used if the user
# creates his/her own .bashrc/.bash_profile

# --show-control-chars: help showing Korean or accented characters
alias ls='ls -F --color=auto --show-control-chars'
alias ll='ls -l'

case "$TERM" in
xterm*)
# The following programs are known to require a Win32 Console
# for interactive usage, therefore let's launch them through winpty
# when run inside `mintty`.
for name in node ipython php php5 psql python2.7 winget
do
case "$(type -p "$name".exe 2>/dev/null)" in
''|/usr/bin/*) continue;;
esac
alias $name="winpty $name.exe"
done
;;
esac

我们向 alias 中添加一行自定义的宏定义: alias gph='git push && hexo clean && hexo g && hexo d', 就可以实现在 push 的同时使用 hexo 的部署插件进行部署了!

]]>
@@ -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 @@ -1344,18 +1344,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 @@ -1404,25 +1404,6 @@ - - back-end-guide - - /BackEnd/back-end-guide/ - - 后端开发指南

前言

本文旨在介绍后端开发的基本理念和路径规划。正在不断完善中。

对于后端开发来说,语言只是一个工具和基础。除了语言本身和对应的开发框架外,其他要学的技术在后端开发中往往是通用的,比如:数据库、缓存、消息队列、搜索引擎、Linux、分布式、高并发、设计模式、架构设计等等。

参考

Backend Beginner Roadmap

Backend Roadmap

]]>
- - - - - BackEnd - - - - -
- - - z_logic @@ -1443,16 +1424,16 @@ - games - - /Algorithm/games/ + back-end-guide + + /BackEnd/back-end-guide/ - 博弈论

思考如何必胜态和必败态是什么以及如何构造这样的局面。

【博弈/贪心/交互】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;
}
]]>
+ 后端开发指南

前言

本文旨在介绍后端开发的基本理念和路径规划。正在不断完善中。

对于后端开发来说,语言只是一个工具和基础。除了语言本身和对应的开发框架外,其他要学的技术在后端开发中往往是通用的,比如:数据库、缓存、消息队列、搜索引擎、Linux、分布式、高并发、设计模式、架构设计等等。

参考

Backend Beginner Roadmap

Backend Roadmap

]]>
- Algorithm + BackEnd @@ -1500,11 +1481,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;
}
]]>
@@ -1519,11 +1500,11 @@ - greedy - - /Algorithm/greedy/ + games + + /Algorithm/games/ - 贪心

大胆猜测,小心求证(不会证也没事,做下一题吧)。证明方法总结了以下几种

  • 反证法:假设取一种方案比贪心方案更好,得出相反的结论
  • 边界法:从边界开始考虑,因为满足边界条件更加容易枚举,从而进行后续的贪心
  • 直觉法:遵循社会法则()

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;
}
]]>
+ 博弈论

思考如何必胜态和必败态是什么以及如何构造这样的局面。

【博弈/贪心/交互】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;
}
]]>
@@ -1538,11 +1519,11 @@ - prefix-and-difference - - /Algorithm/prefix-and-difference/ + hashing + + /Algorithm/hashing/ - 前缀和与差分

前缀和是正向思维,差分是前缀和的逆向思维。

【差分/排序】充能计划

https://www.lanqiao.cn/problems/8732/learning/?contest_id=147

题意:给定 nn 个数初始化为 00,现在给定 qq 个位置,每个位置给定两个参数 p,kp,k,表示从第 kk 个数开始连续 s[p]s[p] 个数 +1+1,返回最终每一个位置的数值。+1 操作有以下两种约束:

  1. 如果连续 s[p]s[p] 个数越界了,则越界的部分就不 +1+1
  2. 一个位置最多只能被一种 pp 对应的种类 +1+1

思路:

  • 现在假设只有一个种类的p,如果不考虑上述第二个条件的约束,那么就是纯差分。如果考虑了,那么我们从左到右考虑+1区间覆盖的问题,就需要判断当前位置是否被上一个+1区间覆盖过,解决办法就是记录上一个区间覆盖的起始点or终止点,这里选择起始点。
  • 现在我们考虑多个种类的p,那么就是分种类重复上述思路即可,因为不同种类之间是没有约束上的冲突的。那么如何分种类解决呢,我们可以对输入的q个位置的所有p、k参数进行排序,p为第一关键词,k为第二个关键词。
  • 时间复杂度:Θ(qlogq+q+n)\Theta(q\log q+q+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
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

const int N = 100010;

struct node {
int p, k; // 第p种宝石丢在了第k个坑
bool operator<(const node &t) const {
if (p == t.p) {
return k > t.k;
} else {
return p > t.p;
}
}
};

int n, m, q, s[N]; // n个坑,m种宝石,q个采集的宝石,s[i]表示第i种宝石的能量
vector<int> last(N, -1e6); // last[i]表示上一个第i种宝石的位置
int a[N], b[N]; // a[]为原数组,b[]为差分数组
priority_queue<node> que;

void solve() {
cin >> n >> m >> q;
for (int i = 1; i <= m; i++) {
cin >> s[i];
}

while (q--) {
int p, k;
cin >> p >> k;
que.push({p, k});
}

while (que.size()) {
auto h = que.top();
que.pop();
int p = h.p, k = h.k; // 第p种宝石丢在了第k个坑

int l, r;
if (k - last[p] >= s[p]) {
// 和上一种没有重叠
l = k, r = min(n, k + s[p] - 1);
b[l]++, b[r + 1]--;
last[p] = k;
} else {
// 和上一种有重叠
l = last[p] + s[p];
r = min(n, k + s[p] - 1);
if (l <= r) {
b[l]++, b[r + 1]--;
}
last[p] = k;
}
}

for (int i = 1; i <= n; i++) {
a[i] = a[i - 1] + b[i];
cout << a[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;
}

【前缀和/二分答案】或值至少为 K 的最短子数组 II

https://leetcode.cn/problems/shortest-subarray-with-or-at-least-k-ii/description/

题意:给定一个序列,问其中所有连续的序列中,所有元素或值的结果超过 k 的序列长度最短是多少

  • 思路一:枚举。很显然我们可以枚举所有的长度、所有的序列、所有的或值进行判断

    时间复杂度:O(n3)O(n^3)

  • 思路二:二分。可以发现序列的长度越长,序列中所有元素的或值越有可能超过 k,可以二分答案。但是对于每一次二分出来的答案,检查函数都需要 O(n2)O(n^2) 来完成

    时间复杂度:O(n2logn)O(n^2\log n)

  • 思路三:二分+前缀和。有没有什么方法可以加速检查呢?答案是有的。我们从或运算的根本出发,序列中所有元素的或值取决于每一个数的每一个二进制位。对于所有的序列中的数来说,如果第 j 个二进制位出现了 1,则无论别的数什么情况,最终这一位或出来的结果一定是 1 << j。于是对于 O(n)O(n) 的或运算,我们就可以通过前缀和的方式简化为 O(30)O(30)。即我们利用二维数组存储每一个数的二进制位的结果,其中 a[i][j] 表示前 i 个数的第 j 个二进制位的相加的结果。后续在计算区间中元素的或值时,只需要检查区间中 30 个二进制位是否含有 1 即可。

    时间复杂度:O(30nlogn)O(30n\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
const int N = 2e5 + 10;

int a[N][35];

class Solution {
public:
bool chk(int x, vector<int>& nums, int k) {
int n = nums.size();

for (int i = x; i <= n; i++) {
int res = 0;
for (int j = 0; j <= 30; j++) {
if (a[i][j] - a[i - x][j]) res += 1 << j;
}
if (res >= k) return true;
}

return false;
}

int minimumSubarrayLength(vector<int>& nums, int k) {
int n = nums.size();

// 维护前缀和
for (int i = 1; i <= n; i++) {
for (int j = 0; j <= 30; j++) {
if (1 << j & nums[i - 1]) {
a[i][j] = a[i - 1][j] + 1;
} else {
a[i][j] = a[i - 1][j];
}
}
}

// 二分答案
int l = 1, r = n;
while (l < r) {
int mid = (l + r) >> 1;
if (chk(mid, nums, k)) r = mid;
else l = mid + 1;
}

// 检查结果
for (int i = r; i <= n; i++) {
int res = 0;
for (int j = 0; j <= 30; j++) {
if (a[i][j] - a[i - r][j]) {
res += 1 << j;
}
}
if (res >= k) return r;
}

return -1;
}
};

【差分/贪心】增减序列

https://www.acwing.com/problem/content/description/102/

标签:差分、贪心

题意:给定一个序列,每次可以对序列中的子数组进行同时 +1-1 的操作,问最少操作多少次可以使得最终的数组所有元素都相等。并给出在最少操作次数的情况下,有多少种操作方案。

思路:区间同时操作可以联想到差分,我们定义原始数组为 s[],差分数组为 a[],并且下标均从 1 开始。那么对于「所有元素都相等」这个约束,可以发现所有元素在操作相等后差分数组 a[2:n] 均为 0。因此本题转化为对差分数组 a[2:n] 中的元素进行 +1-1 操作使得最终的差分数组中 a[2:n] 均为 0。一个很显然的贪心思路就是每次在 a[2:n] 中选择一对符号相反的数,对其正数进行 -1 操作,对其负数进行 +1 操作。最终可能会因为无法匹配剩余某个正数或负数,我们假设其下标为 i,此时的序列就是 s[1:i] 全部相等,s[i+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
#include <bits/stdc++.h>

using ll = long long;
using namespace std;

void solve() {
int n;
cin >> n;

vector<int> s(n + 1);
for (int i = 1; i <= n; i++) {
cin >> s[i];
}

ll neg = 0, pos = 0;
for (int i = 2; i <= n; i++) {
ll x = s[i] - s[i - 1];
if (x > 0) pos += x;
else neg += x;
}

cout << max(pos, abs(neg)) << "\n" << abs(pos - abs(neg)) + 1 << "\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
from collections import defaultdict
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 = II()
s = [0] * (n + 1)
pos, neg = 0, 0
for i in range(1, n + 1):
s[i] = II()
for i in range(2, n + 1):
x = s[i] - s[i - 1]
if x > 0: pos += x
else: neg += x
print(f"{max(pos, abs(neg))}\n{abs(pos - abs(neg)) + 1}")


if __name__ == '__main__':
T = 1
# T = II()
while T: solve(); T -= 1
]]>
+ 哈希

【哈希】分组

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)))
]]>
@@ -1576,11 +1557,11 @@ - a_template - - /Algorithm/a_template/ + prefix-and-difference + + /Algorithm/prefix-and-difference/ - 板子

优雅的解法,少不了优雅的板子。目前仅编写 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
11
12
13
bool findLeft(int x) {
int l = 0, r = n - 1;
while (l < r) {
int mid = (l + r) >> 1;
// if (a[mid] < x) l = mid + 1;
if (缺了) {
l = mid + 1;
} else (刚好 | 超了) {
r = mid;
}
}
return a[r] == x;
}

闭区间寻找右边界:

1
2
3
4
5
6
7
8
9
10
11
12
13
bool findRight(int x) {
int l = 0, r = n - 1;
while (l < r) {
int mid = (l + r + 1) >> 1;
// if (a[mid] <= x) l = mid;
if (缺了 | 刚好) {
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 位,范围内的数据正常打印,最后一位四舍五入,范围外的数据未知。

]]>
+ 前缀和与差分

前缀和是正向思维,差分是前缀和的逆向思维。

【差分/排序】充能计划

https://www.lanqiao.cn/problems/8732/learning/?contest_id=147

题意:给定 nn 个数初始化为 00,现在给定 qq 个位置,每个位置给定两个参数 p,kp,k,表示从第 kk 个数开始连续 s[p]s[p] 个数 +1+1,返回最终每一个位置的数值。+1 操作有以下两种约束:

  1. 如果连续 s[p]s[p] 个数越界了,则越界的部分就不 +1+1
  2. 一个位置最多只能被一种 pp 对应的种类 +1+1

思路:

  • 现在假设只有一个种类的p,如果不考虑上述第二个条件的约束,那么就是纯差分。如果考虑了,那么我们从左到右考虑+1区间覆盖的问题,就需要判断当前位置是否被上一个+1区间覆盖过,解决办法就是记录上一个区间覆盖的起始点or终止点,这里选择起始点。
  • 现在我们考虑多个种类的p,那么就是分种类重复上述思路即可,因为不同种类之间是没有约束上的冲突的。那么如何分种类解决呢,我们可以对输入的q个位置的所有p、k参数进行排序,p为第一关键词,k为第二个关键词。
  • 时间复杂度:Θ(qlogq+q+n)\Theta(q\log q+q+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
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

const int N = 100010;

struct node {
int p, k; // 第p种宝石丢在了第k个坑
bool operator<(const node &t) const {
if (p == t.p) {
return k > t.k;
} else {
return p > t.p;
}
}
};

int n, m, q, s[N]; // n个坑,m种宝石,q个采集的宝石,s[i]表示第i种宝石的能量
vector<int> last(N, -1e6); // last[i]表示上一个第i种宝石的位置
int a[N], b[N]; // a[]为原数组,b[]为差分数组
priority_queue<node> que;

void solve() {
cin >> n >> m >> q;
for (int i = 1; i <= m; i++) {
cin >> s[i];
}

while (q--) {
int p, k;
cin >> p >> k;
que.push({p, k});
}

while (que.size()) {
auto h = que.top();
que.pop();
int p = h.p, k = h.k; // 第p种宝石丢在了第k个坑

int l, r;
if (k - last[p] >= s[p]) {
// 和上一种没有重叠
l = k, r = min(n, k + s[p] - 1);
b[l]++, b[r + 1]--;
last[p] = k;
} else {
// 和上一种有重叠
l = last[p] + s[p];
r = min(n, k + s[p] - 1);
if (l <= r) {
b[l]++, b[r + 1]--;
}
last[p] = k;
}
}

for (int i = 1; i <= n; i++) {
a[i] = a[i - 1] + b[i];
cout << a[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;
}

【前缀和/二分答案】或值至少为 K 的最短子数组 II

https://leetcode.cn/problems/shortest-subarray-with-or-at-least-k-ii/description/

题意:给定一个序列,问其中所有连续的序列中,所有元素或值的结果超过 k 的序列长度最短是多少

  • 思路一:枚举。很显然我们可以枚举所有的长度、所有的序列、所有的或值进行判断

    时间复杂度:O(n3)O(n^3)

  • 思路二:二分。可以发现序列的长度越长,序列中所有元素的或值越有可能超过 k,可以二分答案。但是对于每一次二分出来的答案,检查函数都需要 O(n2)O(n^2) 来完成

    时间复杂度:O(n2logn)O(n^2\log n)

  • 思路三:二分+前缀和。有没有什么方法可以加速检查呢?答案是有的。我们从或运算的根本出发,序列中所有元素的或值取决于每一个数的每一个二进制位。对于所有的序列中的数来说,如果第 j 个二进制位出现了 1,则无论别的数什么情况,最终这一位或出来的结果一定是 1 << j。于是对于 O(n)O(n) 的或运算,我们就可以通过前缀和的方式简化为 O(30)O(30)。即我们利用二维数组存储每一个数的二进制位的结果,其中 a[i][j] 表示前 i 个数的第 j 个二进制位的相加的结果。后续在计算区间中元素的或值时,只需要检查区间中 30 个二进制位是否含有 1 即可。

    时间复杂度:O(30nlogn)O(30n\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
const int N = 2e5 + 10;

int a[N][35];

class Solution {
public:
bool chk(int x, vector<int>& nums, int k) {
int n = nums.size();

for (int i = x; i <= n; i++) {
int res = 0;
for (int j = 0; j <= 30; j++) {
if (a[i][j] - a[i - x][j]) res += 1 << j;
}
if (res >= k) return true;
}

return false;
}

int minimumSubarrayLength(vector<int>& nums, int k) {
int n = nums.size();

// 维护前缀和
for (int i = 1; i <= n; i++) {
for (int j = 0; j <= 30; j++) {
if (1 << j & nums[i - 1]) {
a[i][j] = a[i - 1][j] + 1;
} else {
a[i][j] = a[i - 1][j];
}
}
}

// 二分答案
int l = 1, r = n;
while (l < r) {
int mid = (l + r) >> 1;
if (chk(mid, nums, k)) r = mid;
else l = mid + 1;
}

// 检查结果
for (int i = r; i <= n; i++) {
int res = 0;
for (int j = 0; j <= 30; j++) {
if (a[i][j] - a[i - r][j]) {
res += 1 << j;
}
}
if (res >= k) return r;
}

return -1;
}
};

【差分/贪心】增减序列

https://www.acwing.com/problem/content/description/102/

标签:差分、贪心

题意:给定一个序列,每次可以对序列中的子数组进行同时 +1-1 的操作,问最少操作多少次可以使得最终的数组所有元素都相等。并给出在最少操作次数的情况下,有多少种操作方案。

思路:区间同时操作可以联想到差分,我们定义原始数组为 s[],差分数组为 a[],并且下标均从 1 开始。那么对于「所有元素都相等」这个约束,可以发现所有元素在操作相等后差分数组 a[2:n] 均为 0。因此本题转化为对差分数组 a[2:n] 中的元素进行 +1-1 操作使得最终的差分数组中 a[2:n] 均为 0。一个很显然的贪心思路就是每次在 a[2:n] 中选择一对符号相反的数,对其正数进行 -1 操作,对其负数进行 +1 操作。最终可能会因为无法匹配剩余某个正数或负数,我们假设其下标为 i,此时的序列就是 s[1:i] 全部相等,s[i+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
#include <bits/stdc++.h>

using ll = long long;
using namespace std;

void solve() {
int n;
cin >> n;

vector<int> s(n + 1);
for (int i = 1; i <= n; i++) {
cin >> s[i];
}

ll neg = 0, pos = 0;
for (int i = 2; i <= n; i++) {
ll x = s[i] - s[i - 1];
if (x > 0) pos += x;
else neg += x;
}

cout << max(pos, abs(neg)) << "\n" << abs(pos - abs(neg)) + 1 << "\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
from collections import defaultdict
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 = II()
s = [0] * (n + 1)
pos, neg = 0, 0
for i in range(1, n + 1):
s[i] = II()
for i in range(2, n + 1):
x = s[i] - s[i - 1]
if x > 0: pos += x
else: neg += x
print(f"{max(pos, abs(neg))}\n{abs(pos - abs(neg)) + 1}")


if __name__ == '__main__':
T = 1
# T = II()
while T: solve(); T -= 1
]]>
@@ -1608,11 +1589,11 @@ - divide-and-conquer - - /Algorithm/divide-and-conquer/ + binary-search + + /Algorithm/binary-search/ - 分治

将大问题转化为等价小问题进行求解。

【分治】随机排列

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;
}
]]>
+ 二分

二分本质上是一个线性的算法思维,只是比线性思维更进一步的是,二分思维需要提炼出题面中两个线性相关的变量,即单调变化的两个变量,从而采用二分加速检索。

【二分答案】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;
}
]]>
@@ -1627,11 +1608,11 @@ - binary-search - - /Algorithm/binary-search/ + a_template + + /Algorithm/a_template/ - 二分

二分本质上是一个线性的算法思维,只是比线性思维更进一步的是,二分思维需要提炼出题面中两个线性相关的变量,即单调变化的两个变量,从而采用二分加速检索。

【二分答案】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;
}
]]>
+ 板子

优雅的解法,少不了优雅的板子。目前仅编写 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
11
12
13
bool findLeft(int x) {
int l = 0, r = n - 1;
while (l < r) {
int mid = (l + r) >> 1;
// if (a[mid] < x) l = mid + 1;
if (缺了) {
l = mid + 1;
} else (刚好 | 超了) {
r = mid;
}
}
return a[r] == x;
}

闭区间寻找右边界:

1
2
3
4
5
6
7
8
9
10
11
12
13
bool findRight(int x) {
int l = 0, r = n - 1;
while (l < r) {
int mid = (l + r + 1) >> 1;
// if (a[mid] <= x) l = mid;
if (缺了 | 刚好) {
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
20
21
22
23
24
25
26
27
28
class BinaryIndexedTree:
def __init__(self, n: int):
"""
初始化序列 O(n)。下标从 1 开始,初始化维护序列区间为 [1,n]。
"""
self.n = n
self.arr = [0] * (n + 1)

def update(self, pos: int, x: int) -> None:
"""
单点修改 O(log n)。在 pos 这个位置加上 x。
"""
while pos <= self.n:
self.arr[pos] += x
pos += self._lowbit(pos)

def query_sum(self, pos: int) -> int:
"""
区间求和 O(log n)。返回 [1,pos] 的区间和。
"""
ret = 0
while pos:
ret += self.arr[pos]
pos -= self._lowbit(pos)
return ret

def _lowbit(self, x: int) -> int:
return x & (-x)

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 位,范围内的数据正常打印,最后一位四舍五入,范围外的数据未知。

]]>
@@ -1646,11 +1627,11 @@ - dfs-and-similar - - /Algorithm/dfs-and-similar/ + data-structure + + /Algorithm/data-structure/ - 搜索

无论是深搜还是宽搜,都逃不掉图的思维。我们将搜索图建立起来之后,剩余的编码过程就会跃然纸上。

【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
]]>
+ 数据结构

数据结构由 数据结构 两部分组成。我们主要讨论的是后者,即结构部分。

按照 逻辑结构 可以将其区分为 线性结构非线性结构

线性数据结构 与 非线性数据结构

按照 物理结构 可以将其区分为 连续结构分散结构

连续空间存储 与 分散空间存储

【模板】双链表

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

]]>
@@ -1665,11 +1646,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;
}
]]>
@@ -1702,26 +1683,45 @@ + + dfs-and-similar + + /Algorithm/dfs-and-similar/ + + 搜索

无论是深搜还是宽搜,都逃不掉图的思维。我们将搜索图建立起来之后,剩余的编码过程就会跃然纸上。

【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
]]>
+ + + + + Algorithm + + + + +
+ + + - 关于作者 + 心语 / - 教育经历

层次时间学校方向地址高中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/15/index.html b/page/15/index.html index af7e55d24..3a3c7dbed 100644 --- a/page/15/index.html +++ b/page/15/index.html @@ -379,15 +379,15 @@

- - operation-guide + + road-map-guide

- +
- 运维指南 前言 本文旨在介绍运维的基本理念和路径规划。正在不断完善中。 云计算运维的相关知识包括但不限于: Linux 基础:常用命令、文件及用户管理、文本处理、Vim 工具使用。 网络基础:网络基础知识、TCP/IP 协议、七层网络模型、Linux 网络管理与配置。 服务器运维:SSH 远程连接、文件上传下载、Nginx 和 MySQL 服务器搭建、LVS 负载均衡配置以及服务器优化经验。 自 + 学习路线指南 参考:https://www.codefather.cn/学习路线/ 注:打星 (∗)(*)(∗) 内容为上述链接未涉及到的路线,侵权必删 计算机基础 计算机网络 参考:https://www.code-nav.cn/post/1640588119619551233 操作系统 参考:https://www.code-nav.cn/post/1640587909942099969 软件
@@ -411,7 +411,7 @@

- Operation + RoadMap diff --git a/page/16/index.html b/page/16/index.html index e933c04b3..36c8bd1c0 100644 --- a/page/16/index.html +++ b/page/16/index.html @@ -250,15 +250,15 @@

- - road-map-guide + + operation-guide

- +
- 学习路线指南 参考:https://www.codefather.cn/学习路线/ 注:打星 (∗)(*)(∗) 内容为上述链接未涉及到的路线,侵权必删 计算机基础 计算机网络 参考:https://www.code-nav.cn/post/1640588119619551233 操作系统 参考:https://www.code-nav.cn/post/1640587909942099969 软件 + 运维指南 前言 本文旨在介绍运维的基本理念和路径规划。正在不断完善中。 云计算运维的相关知识包括但不限于: Linux 基础:常用命令、文件及用户管理、文本处理、Vim 工具使用。 网络基础:网络基础知识、TCP/IP 协议、七层网络模型、Linux 网络管理与配置。 服务器运维:SSH 远程连接、文件上传下载、Nginx 和 MySQL 服务器搭建、LVS 负载均衡配置以及服务器优化经验。 自
@@ -282,7 +282,7 @@

- RoadMap + Operation @@ -365,15 +365,15 @@

- - OptMethod + + MachineLearning

- +
- 最优化方法 前言 学科地位: 主讲教师 学分配额 学科类别 王启春 4 专业课 成绩组成: 平时(作业+考勤) 期中(大作业) 期末(闭卷) 20% 30% 50% 教材情况: 课程名称 选用教材 版次 作者 出版社 ISBN号 最优化算法 《最优化方法》 2 孙文瑜 高等教育出版社 978-7-04-029763-8 第一章 基本概念 + 机器学习 前言 本博客初稿完成与大二下学期,记录 Machine Learning 相关内容。主要参考 《机器学习与模式识别》· 周志华 · 清华大学出版社 和 南瓜书 《机器学习》(西瓜书)公式详解。 1 绪论 术语 含义 机器学习定义 利用 经验 改善系统自身性能,主要研究 智能数据分析 的理论和方法。 计算学习理论 最重要的理论模型是 PAC(Probably Approx
diff --git a/page/17/index.html b/page/17/index.html index 629edd56e..ea1142176 100644 --- a/page/17/index.html +++ b/page/17/index.html @@ -250,15 +250,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 一
@@ -311,15 +311,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
@@ -372,15 +372,15 @@

- - SysBasic + + OptMethod

- +
- 计算机系统基础 前言 学科地位: 主讲教师 学分配额 学科类别 闫文珠 3.5 专业课 成绩组成: 实验(9次) 平时作业 期末(闭卷) 20% 30% 50% 教材情况: 课程名称 选用教材 版次 作者 出版社 ISBN号 计算机系统基础 《计算机系统基础》 4 袁春风 机械工业出版社 978-7-111-60489-1 学习资源: + 最优化方法 前言 学科地位: 主讲教师 学分配额 学科类别 王启春 4 专业课 成绩组成: 平时(作业+考勤) 期中(大作业) 期末(闭卷) 20% 30% 50% 教材情况: 课程名称 选用教材 版次 作者 出版社 ISBN号 最优化算法 《最优化方法》 2 孙文瑜 高等教育出版社 978-7-04-029763-8 第一章 基本概念
diff --git a/page/18/index.html b/page/18/index.html index 8b4bdafe3..09e1b9885 100644 --- a/page/18/index.html +++ b/page/18/index.html @@ -250,15 +250,15 @@

- - MachineLearning + + SysBasic

- +
- 机器学习与模式识别 前言 学科地位: 主讲教师 学分配额 学科类别 杨琬琪 3 专业课 成绩组成: 理论课: 作业+课堂 课堂测验(2次) 期末(闭卷) 30% 20% 50% 实验课: 19次实验(五级制) 100% 教材情况: 课程名称 选用教材 版次 作者 出版社 ISBN号 机器学习与模式识别 《机器学习与模式识别》 + 计算机系统基础 前言 学科地位: 主讲教师 学分配额 学科类别 闫文珠 3.5 专业课 成绩组成: 实验(9次) 平时作业 期末(闭卷) 20% 30% 50% 教材情况: 课程名称 选用教材 版次 作者 出版社 ISBN号 计算机系统基础 《计算机系统基础》 4 袁春风 机械工业出版社 978-7-111-60489-1 学习资源:
@@ -311,15 +311,15 @@

- - DataStructure + + DataStructureClassDesign

- +
- score:\mathscr {score:}score: 平时 20%(出勤、作业、实验) 期中 20% 期末 60% 数据结构 完整实现代码:https://github.com/Explorer-Dong/DataStructure 一、绪论 1.1 数据分析+结构存储+算法计算 1.1.1 逻辑结构 对于当前的数据之间的关系进行分析,进而思考应该如何存储,有以下几种逻辑结构:集合、 + 数据结构课程设计 效果演示 [!note] 以下为课设报告内容 一、必做题 题目:编程实现希尔、快速、堆排序、归并排序算法。要求随机产生 10000 个数据存入磁盘文件,然后读入数据文件,分别采用不同的排序方法进行排序,并将结果存入文件中。 1.1 数据结构设计与算法思想 本程序涉及到四种排序算法,其中: 希尔排序:通过倍增方法逐渐扩大选择插入数据量的规模,而达到减小数据比较的次数,时间
@@ -372,15 +372,15 @@

- - DataStructureClassDesign + + CollegePhysics_2

- +
- 数据结构课程设计 效果演示 [!note] 以下为课设报告内容 一、必做题 题目:编程实现希尔、快速、堆排序、归并排序算法。要求随机产生 10000 个数据存入磁盘文件,然后读入数据文件,分别采用不同的排序方法进行排序,并将结果存入文件中。 1.1 数据结构设计与算法思想 本程序涉及到四种排序算法,其中: 希尔排序:通过倍增方法逐渐扩大选择插入数据量的规模,而达到减小数据比较的次数,时间 + 大学物理下 第九章 振动 9.1 简谐振动 振幅 周期和频率 相位 9.1.1 简谐振动 定义:加速度与位移成正比且方向相反,的运动叫做简谐振动 恢复力 FFF: F=−kxF=-kx F=−kx 加速度 aaa: a=Fm=−kmxa=\frac{F}{m}=-\frac{k}{m}x a=mF​=−mk​x 将 km\frac{k}{m}mk​ 代换为 ω2\omega^2ω2,得到 aaa
diff --git a/page/19/index.html b/page/19/index.html index 06dc07a39..ea1ec1958 100644 --- a/page/19/index.html +++ b/page/19/index.html @@ -250,15 +250,15 @@

- - CollegePhysics_2 + + DataStructure

- +
- 大学物理下 第九章 振动 9.1 简谐振动 振幅 周期和频率 相位 9.1.1 简谐振动 定义:加速度与位移成正比且方向相反,的运动叫做简谐振动 恢复力 FFF: F=−kxF=-kx F=−kx 加速度 aaa: a=Fm=−kmxa=\frac{F}{m}=-\frac{k}{m}x a=mF​=−mk​x 将 km\frac{k}{m}mk​ 代换为 ω2\omega^2ω2,得到 aaa + score:\mathscr {score:}score: 平时 20%(出勤、作业、实验) 期中 20% 期末 60% 数据结构 完整实现代码:https://github.com/Explorer-Dong/DataStructure 一、绪论 1.1 数据分析+结构存储+算法计算 1.1.1 逻辑结构 对于当前的数据之间的关系进行分析,进而思考应该如何存储,有以下几种逻辑结构:集合、
@@ -311,15 +311,15 @@

- - DigitalLogicCircuit + + LinearAlgebra

- +
- 数字逻辑电路 1 概论 1.1 数字信号 | 数字电路 1.1.1 数字技术的发展及其应用 电流控制器件:电子管、晶体管(二极管、三极管)、半导体集成电路 EDA (Electronic Design Automation) 技术(硬件设计软件化):设计:EWB or Verilog、仿真、下载、验证结果 1.1.2 数字集成电路的分类及特点 数字集成电路的分类 从结构特点及其 + 线性代数 前言 本篇博客初稿完成于 2024.01.06,即大二上学期期末。参考《工程数学 线性代数》同济第七版。 由于初稿写作时博主的水平有限并且偏向于应试,写作水平多有不足并且内容有所缺失。现在在学习 ML 和 DL 甚至数字图像处理时,几乎满屏都是矩阵。汗颜的是,由于初学时几乎都是死记硬背以及应试,我一度怀疑自己没学过线代🤡,因此这篇博客会持续更新。 后续的更新会对内容进行整合与补充,包括
@@ -372,15 +372,15 @@

- - LinearAlgebra + + DigitalLogicCircuit

- +
- 线性代数 前言 本篇博客初稿完成于 2024.01.06,即大二上学期期末。参考《工程数学 线性代数》同济第七版。 由于初稿写作时博主的水平有限并且偏向于应试,写作水平多有不足并且内容有所缺失。现在在学习 ML 和 DL 甚至数字图像处理时,几乎满屏都是矩阵。汗颜的是,由于初学时几乎都是死记硬背以及应试,我一度怀疑自己没学过线代🤡,因此这篇博客会持续更新。 后续的更新会对内容进行整合与补充,包括 + 数字逻辑电路 1 概论 1.1 数字信号 | 数字电路 1.1.1 数字技术的发展及其应用 电流控制器件:电子管、晶体管(二极管、三极管)、半导体集成电路 EDA (Electronic Design Automation) 技术(硬件设计软件化):设计:EWB or Verilog、仿真、下载、验证结果 1.1.2 数字集成电路的分类及特点 数字集成电路的分类 从结构特点及其
diff --git a/page/20/index.html b/page/20/index.html index c568c4082..8054f9c33 100644 --- a/page/20/index.html +++ b/page/20/index.html @@ -311,15 +311,15 @@

- - hexo-githubpages-domain + + hexo-migrate

- +
- 在 GitHub Pages 中使用自己的域名 如果你觉得使用 [username].github.io 域名过于 ugly,你可以使用自己的域名! 前置条件 购买一个自己的域名:可以在阿里云、腾讯云等大云服务厂商购买域名。价位高低均有,适合自己的即可 使用 GitHub Pages 部署静态站点 开始操作 我们知道,对于域名绑定的服务,如果被绑定的主机位于国内,则需要备案且步骤较为繁琐,但是 + Hexo 迁移指南 前言 为了改变本地项目名称(强迫症),同时为了练习迁移编辑环境,特此记录。 一、克隆仓库 1git clone https://github.com/Explorer-Dong/explorer-dong.github.io.git HexoBlog 二、安装依赖 1npm install 三、编辑推送 123git add .git commit -m 'add: xxx'g
@@ -372,15 +372,15 @@

- - hexo-learning-record + + hexo-githubpages-domain

- +
- Hexo 学习记录 前言 操作系统:Windows 11 OS 建站第一枪,使用 Hexo 静态站点生成器搭建自己的博客网站。首先解释一下什么叫静态站点生成器,其实就是将自己写好的 Markdown 文件自动构建为 HTML 文件,然后进行浏览器渲染,而不需要自己编写前端样式文件。在开始介绍 Hexo 建站步骤之前,最好需要掌握一下技能(不掌握也没事,边建边学也行,我就是这样,不过可能会比较浪 + 在 GitHub Pages 中使用自己的域名 如果你觉得使用 [username].github.io 域名过于 ugly,你可以使用自己的域名! 前置条件 购买一个自己的域名:可以在阿里云、腾讯云等大云服务厂商购买域名。价位高低均有,适合自己的即可 使用 GitHub Pages 部署静态站点 开始操作 我们知道,对于域名绑定的服务,如果被绑定的主机位于国内,则需要备案且步骤较为繁琐,但是
diff --git a/page/21/index.html b/page/21/index.html index b386f788d..6cd00ed20 100644 --- a/page/21/index.html +++ b/page/21/index.html @@ -250,15 +250,15 @@

- - hexo-migrate + + hexo-learning-record

- +
- Hexo 迁移指南 前言 为了改变本地项目名称(强迫症),同时为了练习迁移编辑环境,特此记录。 一、克隆仓库 1git clone https://github.com/Explorer-Dong/explorer-dong.github.io.git HexoBlog 二、安装依赖 1npm install 三、编辑推送 123git add .git commit -m 'add: xxx'g + Hexo 学习记录 前言 操作系统:Windows 11 OS 建站第一枪,使用 Hexo 静态站点生成器搭建自己的博客网站。首先解释一下什么叫静态站点生成器,其实就是将自己写好的 Markdown 文件自动构建为 HTML 文件,然后进行浏览器渲染,而不需要自己编写前端样式文件。在开始介绍 Hexo 建站步骤之前,最好需要掌握一下技能(不掌握也没事,边建边学也行,我就是这样,不过可能会比较浪
@@ -365,15 +365,15 @@

- - git-self-define-command + + git-basic

- +
- Git 自定义命令 前言 在使用 hexo 搭建个人博客时, 共两种部署的方法. 分别为: 本地利用 hexo 的插件 hexo-deployer-git 来实现部署, 缺点是需要多敲几个命令行且不方便对源码进行云端备份 使用 Github Action 的 workflow 自动化部署, 优势就是可以在 push 备份源码的同时自动检测行为并自动构建部署代码 看似十分方便但是问题也出在这, + Git 基础 前言 Git 是一款版本管理软件, 适用目前绝大多数操作系统; Github 是一个代码托管平台, 与 Git 没有任何关系. 只不过 Git 可以基于 Github 进行分布式云存储与交互, 因此往往需要结合二者从而达到相对良好的 Teamwork 状态. 本文是我基于 Git 的版本管理学习记录, 涉及到的指令只是冰山一角, 但是使用频率较高. 详细的指令请跳转至官方教学: ht
diff --git a/page/22/index.html b/page/22/index.html index d1f1d3432..a2d5f8c17 100644 --- a/page/22/index.html +++ b/page/22/index.html @@ -250,15 +250,15 @@

- - git-basic + + git-self-define-command

- +
- Git 基础 前言 Git 是一款版本管理软件, 适用目前绝大多数操作系统; Github 是一个代码托管平台, 与 Git 没有任何关系. 只不过 Git 可以基于 Github 进行分布式云存储与交互, 因此往往需要结合二者从而达到相对良好的 Teamwork 状态. 本文是我基于 Git 的版本管理学习记录, 涉及到的指令只是冰山一角, 但是使用频率较高. 详细的指令请跳转至官方教学: ht + Git 自定义命令 前言 在使用 hexo 搭建个人博客时, 共两种部署的方法. 分别为: 本地利用 hexo 的插件 hexo-deployer-git 来实现部署, 缺点是需要多敲几个命令行且不方便对源码进行云端备份 使用 Github Action 的 workflow 自动化部署, 优势就是可以在 push 备份源码的同时自动检测行为并自动构建部署代码 看似十分方便但是问题也出在这,
@@ -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 @@ -372,15 +372,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 一、编译标准 进入以下目录: 添加以下编译命令: 二、环境选项 三、编辑器选项 进入以下目录: 四、快捷键选项 进入以下目录: 设置注释配置:
@@ -409,7 +409,7 @@

> - CLion + DevCpp diff --git a/page/23/index.html b/page/23/index.html index 80e4edff6..8948d59b2 100644 --- a/page/23/index.html +++ b/page/23/index.html @@ -365,15 +365,15 @@

- - back-end-guide + + z_logic

- +
- 后端开发指南 前言 本文旨在介绍后端开发的基本理念和路径规划。正在不断完善中。 对于后端开发来说,语言只是一个工具和基础。除了语言本身和对应的开发框架外,其他要学的技术在后端开发中往往是通用的,比如:数据库、缓存、消息队列、搜索引擎、Linux、分布式、高并发、设计模式、架构设计等等。 参考 Backend Beginner Roadmap Backend Roadmap + 思维题 多角度切入。 【思维/哈希/排序】最长严格递增子序列 https://www.acwing.com/problem/content/5273/ 题意:给定长度为 n 的序列,问将这个序列拼接 n 次后,最长严格递增子序列的长度为多少? 思路:其实最终的思路很简单,将题目转化为在每个序列中选一个数,一共可以选出多少个不同的数。但是在产生这样的想法之前,先讲一下我的思考过程。我将序列脑补出一
@@ -397,7 +397,7 @@

- BackEnd + Algorithm diff --git a/page/24/index.html b/page/24/index.html index f82090d02..c10bf00c6 100644 --- a/page/24/index.html +++ b/page/24/index.html @@ -250,15 +250,15 @@

- - z_logic + + back-end-guide

- +
- 思维题 多角度切入。 【思维/哈希/排序】最长严格递增子序列 https://www.acwing.com/problem/content/5273/ 题意:给定长度为 n 的序列,问将这个序列拼接 n 次后,最长严格递增子序列的长度为多少? 思路:其实最终的思路很简单,将题目转化为在每个序列中选一个数,一共可以选出多少个不同的数。但是在产生这样的想法之前,先讲一下我的思考过程。我将序列脑补出一 + 后端开发指南 前言 本文旨在介绍后端开发的基本理念和路径规划。正在不断完善中。 对于后端开发来说,语言只是一个工具和基础。除了语言本身和对应的开发框架外,其他要学的技术在后端开发中往往是通用的,比如:数据库、缓存、消息队列、搜索引擎、Linux、分布式、高并发、设计模式、架构设计等等。 参考 Backend Beginner Roadmap Backend Roadmap
@@ -282,7 +282,7 @@

- Algorithm + BackEnd @@ -304,15 +304,15 @@

- - games + + geometry

- +
- 博弈论 思考如何必胜态和必败态是什么以及如何构造这样的局面。 【博弈/贪心/交互】Salyg1n and the MEX Game https://codeforces.com/contest/1867/problem/C 标签:博弈、贪心、交互 题面:对于给定n个数的数列,先手可以放入一个数列中不存在的数(0-1e9),后手可以从数列中拿掉一个数,但是这个数必须严格小于刚才先手放入的数。终止条 + 计算几何 【二维/数学】Minimum Manhattan Distance https://codeforces.com/gym/104639/problem/J 题意:给定两个圆的直径的两个点坐标,其中约束条件是两个圆一定是处在相离的两个角上。问如何在C2圆上或圆内找到一点p,使得点p到C1圆的所有点的曼哈顿距离的期望值最小 思路: 看似需要积分,其实我们可以发现,对于点p到C1中某个点
@@ -358,15 +358,15 @@

- - geometry + + graphs

- +
- 计算几何 【二维/数学】Minimum Manhattan Distance https://codeforces.com/gym/104639/problem/J 题意:给定两个圆的直径的两个点坐标,其中约束条件是两个圆一定是处在相离的两个角上。问如何在C2圆上或圆内找到一点p,使得点p到C1圆的所有点的曼哈顿距离的期望值最小 思路: 看似需要积分,其实我们可以发现,对于点p到C1中某个点 + 图论 【拓扑】有向图的拓扑序列 https://www.acwing.com/problem/content/850/ 题意:输出一个图的拓扑序,不存在则输出-1 思路: 首先我们要知道拓扑图的概念,感官上就是一张图可以从一个方向拓展到全图,用数学语言就是:若一个由图中所有点构成的序列 A 满足:对于图中的每条边 (x,y),x 在 A 中都出现在 y 之前,则称 A 是该图的一个拓扑序列 接
diff --git a/page/25/index.html b/page/25/index.html index b5ea6b662..391549f54 100644 --- a/page/25/index.html +++ b/page/25/index.html @@ -250,15 +250,15 @@

- - graphs + + greedy

- +
- 图论 【拓扑】有向图的拓扑序列 https://www.acwing.com/problem/content/850/ 题意:输出一个图的拓扑序,不存在则输出-1 思路: 首先我们要知道拓扑图的概念,感官上就是一张图可以从一个方向拓展到全图,用数学语言就是:若一个由图中所有点构成的序列 A 满足:对于图中的每条边 (x,y),x 在 A 中都出现在 y 之前,则称 A 是该图的一个拓扑序列 接 + 贪心 大胆猜测,小心求证(不会证也没事,做下一题吧)。证明方法总结了以下几种 反证法:假设取一种方案比贪心方案更好,得出相反的结论 边界法:从边界开始考虑,因为满足边界条件更加容易枚举,从而进行后续的贪心 直觉法:遵循社会法则() 1. green_gold_dog, array and permutation https://codeforces.com/contest/1867/probl
@@ -304,15 +304,15 @@

- - hashing + + games

- +
- 哈希 【哈希】分组 https://www.acwing.com/problem/content/5182/ 存储不想同组和想同组的人员信息:存入数组,数据类型为一对字符串 存储所有的组队信息:存入哈希表,数据类型为“键:字符串”“值:一对字符串” 想要知道最终的分组情况,只需要查询数组中的队员情况与想同组 or 不想同组的成员名字是否一致即可 时间复杂度 O(n)O(n)O(n),空间复杂度 + 博弈论 思考如何必胜态和必败态是什么以及如何构造这样的局面。 【博弈/贪心/交互】Salyg1n and the MEX Game https://codeforces.com/contest/1867/problem/C 标签:博弈、贪心、交互 题面:对于给定n个数的数列,先手可以放入一个数列中不存在的数(0-1e9),后手可以从数列中拿掉一个数,但是这个数必须严格小于刚才先手放入的数。终止条
@@ -358,15 +358,15 @@

- - greedy + + hashing

- +
- 贪心 大胆猜测,小心求证(不会证也没事,做下一题吧)。证明方法总结了以下几种 反证法:假设取一种方案比贪心方案更好,得出相反的结论 边界法:从边界开始考虑,因为满足边界条件更加容易枚举,从而进行后续的贪心 直觉法:遵循社会法则() 1. green_gold_dog, array and permutation https://codeforces.com/contest/1867/probl + 哈希 【哈希】分组 https://www.acwing.com/problem/content/5182/ 存储不想同组和想同组的人员信息:存入数组,数据类型为一对字符串 存储所有的组队信息:存入哈希表,数据类型为“键:字符串”“值:一对字符串” 想要知道最终的分组情况,只需要查询数组中的队员情况与想同组 or 不想同组的成员名字是否一致即可 时间复杂度 O(n)O(n)O(n),空间复杂度
diff --git a/page/26/index.html b/page/26/index.html index 34dd83887..6e4e2a820 100644 --- a/page/26/index.html +++ b/page/26/index.html @@ -250,15 +250,15 @@

- - prefix-and-difference + + number-theory

- +
- 前缀和与差分 前缀和是正向思维,差分是前缀和的逆向思维。 【差分/排序】充能计划 https://www.lanqiao.cn/problems/8732/learning/?contest_id=147 题意:给定 nnn 个数初始化为 000,现在给定 qqq 个位置,每个位置给定两个参数 p,kp,kp,k,表示从第 kkk 个数开始连续 s[p]s[p]s[p] 个数 +1+1+1,返回 + 数论 整数问题。 【质数】Divide and Equalize 题意:给定 nnn 个数,问能否找到一个数 numnumnum,使得 numn=∏i=1nainum^n = \prod_{i=1}^{n}a_inumn=∏i=1n​ai​ 原始思路:起初我的思路是二分,我们需要寻找一个数使得n个数相乘为原数组所有元素之积,那么我们预计算出所有数之积,并且在数组最大值和最小值之间进行二分,每次二
@@ -304,15 +304,15 @@

- - number-theory + + prefix-and-difference

- +
- 数论 整数问题。 【质数】Divide and Equalize 题意:给定 nnn 个数,问能否找到一个数 numnumnum,使得 numn=∏i=1nainum^n = \prod_{i=1}^{n}a_inumn=∏i=1n​ai​ 原始思路:起初我的思路是二分,我们需要寻找一个数使得n个数相乘为原数组所有元素之积,那么我们预计算出所有数之积,并且在数组最大值和最小值之间进行二分,每次二 + 前缀和与差分 前缀和是正向思维,差分是前缀和的逆向思维。 【差分/排序】充能计划 https://www.lanqiao.cn/problems/8732/learning/?contest_id=147 题意:给定 nnn 个数初始化为 000,现在给定 qqq 个位置,每个位置给定两个参数 p,kp,kp,k,表示从第 kkk 个数开始连续 s[p]s[p]s[p] 个数 +1+1+1,返回
@@ -358,15 +358,15 @@

- - a_template + + binary-search

- +
- 板子 优雅的解法,少不了优雅的板子。目前仅编写 C++ 和 Python 语言对应的板子。前者用于备赛 Xcpc,后者用于备赛蓝桥杯。 基础算法 高精度 ▶C++ 1234567891011121314151617181920212223242526272829303132333435363 + 二分 二分本质上是一个线性的算法思维,只是比线性思维更进一步的是,二分思维需要提炼出题面中两个线性相关的变量,即单调变化的两个变量,从而采用二分加速检索。 【二分答案】Building an Aquarium https://codeforces.com/contest/1873/problem/E 题意:想象有一个二维平面,现在有一个数列,每一个数表示平面对应列的高度,现在要给这个平面在两边加
diff --git a/page/27/index.html b/page/27/index.html index 639e403ce..c812d2a60 100644 --- a/page/27/index.html +++ b/page/27/index.html @@ -250,15 +250,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
@@ -304,15 +304,15 @@

- - binary-search + + data-structure

- +
- 二分 二分本质上是一个线性的算法思维,只是比线性思维更进一步的是,二分思维需要提炼出题面中两个线性相关的变量,即单调变化的两个变量,从而采用二分加速检索。 【二分答案】Building an Aquarium https://codeforces.com/contest/1873/problem/E 题意:想象有一个二维平面,现在有一个数列,每一个数表示平面对应列的高度,现在要给这个平面在两边加 + 数据结构 数据结构由 数据 和 结构 两部分组成。我们主要讨论的是后者,即结构部分。 按照 逻辑结构 可以将其区分为 线性结构 和 非线性结构。 按照 物理结构 可以将其区分为 连续结构 和 分散结构。 【模板】双链表 https://www.acwing.com/problem/content/829/ 思路:用两个空结点作为起始状态的边界,避免所有边界讨论。 时间复杂度:插入、删除结点均
@@ -358,15 +358,15 @@

- - dfs-and-similar + + divide-and-conquer

- +
- 搜索 无论是深搜还是宽搜,都逃不掉图的思维。我们将搜索图建立起来之后,剩余的编码过程就会跃然纸上。 【dfs】机器人的运动范围 https://www.acwing.com/problem/content/22/ 1234567891011121314151617181920212223242526272829303132333435class Solution {public: int r + 分治 将大问题转化为等价小问题进行求解。 【分治】随机排列 https://www.acwing.com/problem/content/5469/ 题意:给定一个 n 个数的全排列序列,并将其进行一定的对换,问是对换了 3n 次还是 7n+1 次 思路:可以发现对于两种情况,就对应对换次数的奇偶性。当 n 为奇数:3n 为奇数,7n+1 为偶数;当 n 为偶数:3n 为偶数,7n+1 为奇数。
diff --git a/page/28/index.html b/page/28/index.html index efda43b0e..f5b6eac8c 100644 --- a/page/28/index.html +++ b/page/28/index.html @@ -250,15 +250,15 @@

- - data-structure + + dp

- +
- 数据结构 数据结构由 数据 和 结构 两部分组成。我们主要讨论的是后者,即结构部分。 按照 逻辑结构 可以将其区分为 线性结构 和 非线性结构。 按照 物理结构 可以将其区分为 连续结构 和 分散结构。 【模板】双链表 https://www.acwing.com/problem/content/829/ 思路:用两个空结点作为起始状态的边界,避免所有边界讨论。 时间复杂度:插入、删除结点均 + 动态规划 动态规划分为被动转移和主动转移,而其根本在于状态表示和状态转移。如何完整表示所有状态?如何不重不漏划分子集从而进行状态转移? 【递推】反转字符串 https://www.acwing.com/problem/content/5574/ 题意:给定 n 个字符串,每一个字符串对应一个代价 wiw_iwi​,现在需要对这 n 个字符串进行可能的翻转操作使得最终的 n 个字符串呈现字典序上
@@ -304,15 +304,15 @@

- - dp + + dfs-and-similar

- +
- 动态规划 动态规划分为被动转移和主动转移,而其根本在于状态表示和状态转移。如何完整表示所有状态?如何不重不漏划分子集从而进行状态转移? 【递推】反转字符串 https://www.acwing.com/problem/content/5574/ 题意:给定 n 个字符串,每一个字符串对应一个代价 wiw_iwi​,现在需要对这 n 个字符串进行可能的翻转操作使得最终的 n 个字符串呈现字典序上 + 搜索 无论是深搜还是宽搜,都逃不掉图的思维。我们将搜索图建立起来之后,剩余的编码过程就会跃然纸上。 【dfs】机器人的运动范围 https://www.acwing.com/problem/content/22/ 1234567891011121314151617181920212223242526272829303132333435class Solution {public: int r
diff --git a/page/9/index.html b/page/9/index.html index fcb0e36ca..71f76ade3 100644 --- a/page/9/index.html +++ b/page/9/index.html @@ -258,7 +258,7 @@

- 计算机组成原理 前言 学科地位: 主讲教师 学分配额 学科类别 闫文珠 3.5 自发课 成绩组成: 作业+实验 期末 50% 50% 教材情况: 课程名称 选用教材 版次 作者 出版社 ISBN 号 计算机组成原理 《计算机组成与系统结构》 3 袁春风 清华大学出版社 978-7-302-59988-3 最基本的知识普及: + 计算机组成原理 前言 学科地位: 主讲教师 学分配额 学科类别 闫文珠 3.5 自发课 成绩组成: 作业+实验 期末 50% 50% 教材情况: 课程名称 选用教材 版次 作者 出版社 ISBN 号 计算机组成原理 《计算机组成与系统结构》 3 袁春风 清华大学出版社 978-7-302-59988-3