From cb00683b1eca581c495bf3f587ff5732855a6984 Mon Sep 17 00:00:00 2001 From: Mr_Dwj Date: Wed, 9 Oct 2024 20:40:54 +0800 Subject: [PATCH] Site updated: 2024-10-09 20:40:45 --- Algorithm/a_template/index.html | 4 +- Algorithm/binary-search/index.html | 8 +- Algorithm/data-structure/index.html | 8 +- Algorithm/dfs-and-similar/index.html | 8 +- Algorithm/divide-and-conquer/index.html | 8 +- Algorithm/dp/index.html | 4 +- Algorithm/games/index.html | 8 +- Algorithm/geometry/index.html | 8 +- Algorithm/graphs/index.html | 4 +- Algorithm/greedy/index.html | 4 +- Algorithm/hashing/index.html | 4 +- Algorithm/number-theory/index.html | 4 +- Algorithm/prefix-and-difference/index.html | 4 +- BackEnd/back-end-guide/index.html | 4 +- GPA/3rd-term/CollegePhysics_2/index.html | 4 +- GPA/3rd-term/DataStructure/index.html | 4 +- .../DataStructureClassDesign/index.html | 4 +- GPA/3rd-term/DigitalLogicCircuit/index.html | 8 +- GPA/3rd-term/LinearAlgebra/index.html | 4 +- GPA/4th-term/MachineLearning/index.html | 8 +- GPA/4th-term/OptMethod/index.html | 8 +- GPA/4th-term/PyAlgo/index.html | 4 +- GPA/4th-term/PyApply/index.html | 4 +- GPA/5th-term/ComputerOrganization/index.html | 48 +++--- Operation/Shell/shell-basic/index.html | 142 +++++++++++++----- archives/2024/03/index.html | 8 +- archives/2024/03/page/2/index.html | 12 +- archives/2024/03/page/4/index.html | 28 ++-- archives/2024/03/page/5/index.html | 8 +- archives/2024/page/5/index.html | 8 +- archives/2024/page/6/index.html | 12 +- archives/2024/page/7/index.html | 8 +- archives/2024/page/8/index.html | 24 +-- archives/2024/page/9/index.html | 4 +- archives/page/5/index.html | 8 +- archives/page/6/index.html | 12 +- archives/page/7/index.html | 8 +- archives/page/8/index.html | 24 +-- archives/page/9/index.html | 4 +- categories/Algorithm/index.html | 28 ++-- categories/Algorithm/page/2/index.html | 8 +- categories/GPA/3rd-term/index.html | 12 +- categories/GPA/4th-term/index.html | 8 +- categories/GPA/page/2/index.html | 20 +-- local-search.xml | 128 ++++++++-------- page/16/index.html | 16 +- page/18/index.html | 16 +- page/19/index.html | 8 +- page/24/index.html | 24 +-- page/25/index.html | 24 +-- page/26/index.html | 8 +- page/27/index.html | 16 +- 52 files changed, 438 insertions(+), 364 deletions(-) diff --git a/Algorithm/a_template/index.html b/Algorithm/a_template/index.html index c245096d2..37b9a7bfa 100644 --- a/Algorithm/a_template/index.html +++ b/Algorithm/a_template/index.html @@ -622,8 +622,8 @@

计算几何

- - data-structure + + divide-and-conquer 下一篇 diff --git a/Algorithm/binary-search/index.html b/Algorithm/binary-search/index.html index 9928ebd44..2f598cb17 100644 --- a/Algorithm/binary-search/index.html +++ b/Algorithm/binary-search/index.html @@ -580,9 +580,9 @@

【二分查找】Bomb

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

【二分查找】Bomb

- - dfs-and-similar + + data-structure 下一篇 diff --git a/Algorithm/data-structure/index.html b/Algorithm/data-structure/index.html index 59e113c70..1461c8f5d 100644 --- a/Algorithm/data-structure/index.html +++ b/Algorithm/data-structure/index.html @@ -792,9 +792,9 @@

【线
- + - a_template + binary-search 上一篇 @@ -802,8 +802,8 @@

【线
- - binary-search + + dfs-and-similar 下一篇 diff --git a/Algorithm/dfs-and-similar/index.html b/Algorithm/dfs-and-similar/index.html index c8484b043..b623ad770 100644 --- a/Algorithm/dfs-and-similar/index.html +++ b/Algorithm/dfs-and-similar/index.html @@ -857,9 +857,9 @@

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

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

【分治】随机排列

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

【分治】随机排列

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

【状压dp】Avoid K Palindrome
- + - divide-and-conquer + dfs-and-similar 上一篇 diff --git a/Algorithm/games/index.html b/Algorithm/games/index.html index d2e465f23..0bca8277d 100644 --- a/Algorithm/games/index.html +++ b/Algorithm/games/index.html @@ -426,9 +426,9 @@

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

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

【凸包】奶牛过马路

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

【凸包】奶牛过马路

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

【LCA】树的直径

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

【按位贪心/分类讨论】

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

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

【组合数学】序列数量

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

【差分/贪心】增减序列

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

参考

- - games + + geometry 下一篇 diff --git a/GPA/3rd-term/CollegePhysics_2/index.html b/GPA/3rd-term/CollegePhysics_2/index.html index 7b1486ff9..ceb1eb01f 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 9ffff35ca..4c42f85d6 100644 --- a/GPA/3rd-term/DataStructure/index.html +++ b/GPA/3rd-term/DataStructure/index.html @@ -1405,9 +1405,9 @@

10.8 归并排序

- + - DigitalLogicCircuit + CollegePhysics_2 上一篇 diff --git a/GPA/3rd-term/DataStructureClassDesign/index.html b/GPA/3rd-term/DataStructureClassDesign/index.html index c0fee8e71..4eda3d393 100644 --- a/GPA/3rd-term/DataStructureClassDesign/index.html +++ b/GPA/3rd-term/DataStructureClassDesign/index.html @@ -611,8 +611,8 @@

仓库地址

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

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

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

行列式角度

- + - DataStructureClassDesign + DigitalLogicCircuit 上一篇 diff --git a/GPA/4th-term/MachineLearning/index.html b/GPA/4th-term/MachineLearning/index.html index c8d858cfd..364e88929 100644 --- a/GPA/4th-term/MachineLearning/index.html +++ b/GPA/4th-term/MachineLearning/index.html @@ -1969,9 +1969,9 @@

考试大纲

- + - OptMethod + PyApply 上一篇 @@ -1979,8 +1979,8 @@

考试大纲

- - PyAlgo + + OptMethod 下一篇 diff --git a/GPA/4th-term/OptMethod/index.html b/GPA/4th-term/OptMethod/index.html index eb432d415..952d5ddf6 100644 --- a/GPA/4th-term/OptMethod/index.html +++ b/GPA/4th-term/OptMethod/index.html @@ -1843,9 +1843,9 @@

四、计算 68'

- + - PyApply + MachineLearning 上一篇 @@ -1853,8 +1853,8 @@

四、计算 68'

- - MachineLearning + + PyAlgo 下一篇 diff --git a/GPA/4th-term/PyAlgo/index.html b/GPA/4th-term/PyAlgo/index.html index 3907cacb4..d0a762b0b 100644 --- a/GPA/4th-term/PyAlgo/index.html +++ b/GPA/4th-term/PyAlgo/index.html @@ -504,9 +504,9 @@

考后碎碎念

- + - MachineLearning + OptMethod 上一篇 diff --git a/GPA/4th-term/PyApply/index.html b/GPA/4th-term/PyApply/index.html index f66d8e817..c5ae40e77 100644 --- a/GPA/4th-term/PyApply/index.html +++ b/GPA/4th-term/PyApply/index.html @@ -503,8 +503,8 @@

项目三

- - OptMethod + + MachineLearning 下一篇 diff --git a/GPA/5th-term/ComputerOrganization/index.html b/GPA/5th-term/ComputerOrganization/index.html index a0d173b78..f6558c296 100644 --- a/GPA/5th-term/ComputerOrganization/index.html +++ b/GPA/5th-term/ComputerOrganization/index.html @@ -44,7 +44,7 @@ - + @@ -263,7 +263,7 @@ @@ -274,7 +274,7 @@ - 33 分钟 + 35 分钟 @@ -338,7 +338,7 @@

ComputerOrganization

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

@@ -427,7 +427,7 @@

前言

注:

本书 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 表示,即传送源在右,传送目的在左。

+

为了形式化地描述 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 章重复,详见 https://blog.dwj601.cn/GPA/4th-term/SysBasic/#第1章-计算机系统概述

@@ -468,10 +468,10 @@

3.2 运算部件支持

ALU 实现

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

-

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

+

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」的功能。

+

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 位进行信号选择控制吗?并不是,其中一些虽然功能不同,但是运算逻辑相同:

@@ -484,7 +484,7 @@

3.2 运算部件支持

因此,这 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)。

+

加减运算控制(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)

@@ -509,18 +509,18 @@

整数加减运算

原码乘法运算

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

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

前言

尝试在 Windows11 上使用 wget 通过一个 url 下载一个 js 静态文件。但是发现本地 bash 使用 wget 命令报错:command not found。于是借 wget 之名展开”古老“的 shell 之旅。

Shell 是什么?我们用 GLM4 总结一下:

Shell 的准确定义是:在操作系统中,Shell 是一个程序,它提供了一个用户界面,允许用户通过命令行输入指令来与操作系统交互。它充当用户与操作系统内核之间的中介,处理用户的输入,解释这些输入为操作系统可以理解和执行的操作,并返回执行结果。

也就是说 shell 是一个命令解释器。可以将其理解为人机交互的中间层,通过解析字符串命令与操作系统交互,进而完成系统资源的调用与处理。可以将「解析命令并调用系统资源完成任务」这个过程简单地理解为:shell 解析输入 \to shell 调用对应的 C 函数向 OS 发起请求 API\xrightarrow[]{\text{API}} OS 接受请求并执行相关底层操作 \to OS 返回执行结果给 C 函数 \to shell 接受 C 函数返回结果并显示提示信息。

为什么用 Bash Shell?常见的命令解释器有:Bash (Bourne Again Shell)、Sh (Bourne Shell)、Zsh (Z Shell) 等。其中 Bash 是最流行的跨平台 Shell 之一。虽然它是 Linux 和 macOS 的默认 Shell,但也可以在 Windows 上通过 Windows Subsystem for Linux (WSL) 或第三方工具如 Git Bash 来运行。

文章标题为什么不叫 Linux 基础?在我看来,Linux 基础的内容更多应当偏向于讲解 Linux 的理论知识,然而目前主流的讲解 Linux 基础的内容均偏向于指导大家如何使用 shell 与操作系统交互,那这与 python 等解释型编程语言有什么区别?python 是用 python 解释器与操作系统交互,相关的教程默认都叫「Python 基础」,shell 是用 shell 解释器与操作系统交互,那为什么不叫「Shell 基础」呢?

注:本文基于 Windows11 的 Git Bash 命令解释器配合 Ubuntu22.04 的 Bash 命令解释器展开。两个平台的 Bash 版本信息如下:

Windows11 Git Bash Version Info:

1
2
3
4
5
6
7
$ bash --version
GNU bash, version 5.2.26(1)-release (x86_64-pc-msys)
Copyright (C) 2022 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>

This is free software; you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.

Linux Bash Version Info:

1
2
3
4
5
6
7
root@dwj2:~# bash --version
GNU bash,版本 5.1.16(1)-release (x86_64-pc-linux-gnu)
Copyright (C) 2020 Free Software Foundation, Inc.
许可证 GPLv3+:GNU GPL 许可证第三版或者更新版本 <http://gnu.org/licenses/gpl.html>

本软件是自由软件,您可以自由地更改和重新发布。
在法律许可的情况下特此明示,本软件不提供任何担保。

Shell 基本命令(实验1)

在开始之前,我们可以使用 echo $SHELL 命令查看当前的命令解释器是什么。

常用命令

改变目录 cd

1
cd ../

../ 表示上一级,./ 表示当前一级(也可以不写),/ 表示从根目录开始。

打印目录内容 ls

1
ls

打印当前路径 pwd

1
pwd

打印当前用户名

1
whoami

创建文件夹 mkdir

1
mkdir <FolderName>

创建文件 touch

1
touch <FileName>

复制 cp

1
cp [option] <source> <target>

移动 mv

1
mv [option] <source> <target>

删除 rm

1
rm [option] <source>

打印 echo

1
echo "hello"

将 echo 后面紧跟着的内容打印到命令行解释器中。可以用来查看环境变量的具体值。

也可以配合输出重定向符号 > 将信息打印到文件中实现创建新文件的作用。例如 echo "hello" > file.txt 用于创建 file.txt 文件并且在其中写下 hello 信息。

查看 cat

1
cat [option] <source>

分页查看 more

1
more <source>

与 cat 类似,只不过可以分页展示。按空格键下一页,b 键上一页,q 键退出。

可以配合管道符 | 与别的命令组合从而实现分页展示,例如 tree | more 表示分页打印文件目录。

输出重定向

标准输出(stdout)默认是显示器。

> 表示创建或覆盖,>> 表示追加。

重要命令

查找 find *

1
find <path> <expression>

匹配 grep *

1
grep [option] <pattern> <source>

使用正则表达式在指定文件中进行模式匹配。

用户与权限管理(实验2)

在 Windows 中,我们对用户和权限的概念接触的并不多,因为很多东西都默认设置好了;但是在 Linux 中,很多文件的权限都需要自己配置和定义,因此「用户与权限管理」的操作方法十分重要。我们从现象入手逐个进行讲解。

首先以 root 用户身份登录并进入 /opt/OS/task2/ 目录,然后创建一个测试文件 root_file.txt 和一个测试文件夹 root_folder。使用 ls -l 命令列出当前目录下所有文件的详细信息:

root 用户创建的文件和文件夹

可以看到一共有 66 列信息,从左到右依次为:用户访问权限、与文件链接的个数、文件属主、文件属组、文件大小、最后修改日期、文件/目录名。252-5 列的信息都很显然,第 11 列信息一共有 1010 个字符,其中第 11 个字符表示当前文件的类型,共有如下几种:

文件类型符号
普通文件-
目录d
字符设备文件c
块设备文件b
符号链接l
本地域套接口s
有名管道p

2102-1099 个字符分 33 个一组,分别表示「属主 user 权限、属组 group 权限、其他用户 other 权限」,下面分别介绍「用户和组」以及「权限管理」两个概念。

关于用户和组

首先我们需要明确用户和组这两个概念的定义:

举个例子。对于当前用户 now_user 以及当前用户所在的组 now_group,同组用户 adj_user 和其他用户 other_user 可以形象的理解为以下的集合关系:

graph TB    subgraph ag[another_group]    other_user1    other_user2    end    subgraph ng[now_group]    now_user    adj_user    end

关于权限管理

一共有 3 种权限,如下表所示:

文件目录
r可查看文件能列出目录下内容
w可修改文件能在目录中创建、删除和重命名文件
x可执行文件能进入目录

那么我们平时看到的关于权限还有数字的配置,是怎么回事呢?其实是对上述字符配置的八进制数字化。读 rr 对应 44,写 ww 对应 22,可执行 xx 对应 11,例如如果一个文件对于所有用户都拥有可读、可写、可执行权限,那么就是 rwxrwxrwx,对应到数字就是 777777

相关命令

下面罗列一些用户与权限管理相关的命令,打 * 表示重要命令。

提升权限 sudo

1
sudo ...

使用 sudo 前缀可以使得当前用户的权限提升到 root 权限,如果是 root 用户则无需添加。当然使用 sudo 的前提是将当前用户添加到

查看当前用户 whoami

1
whoami

打印当前用户名。

创建用户 useradd

1
useradd <username>

添加用户。

删除用户 userdel

1
userdel <username>

删除用户。

修改用户信息 usermod

1
usermod

使用 -h 参数查看所有用法。

修改用户密码 passwd

1
passwd <username>

切换用户 su

1
su <username>

添加 - 参数则直接进入 /home/<username>/ 目录(如果有的话)。

查看当前用户所属组 groups

1
groups

创建用户组 groupadd

1
groupadd

删除用户组 groupdel

1
groupdel

改变属主 chown *

1
chown <user> <filename>

将指定文件 filename 更改属主为 user。

改变属组 chgrp *

1
chgrp <group> <filename>

将指定文件 filename 更改属组为 group。

改变权限 chmod *

1
chmod <option> <filename>

将文件 filename 更改所有用户对应的权限。举个例子就知道了:让 demo.py 文件只能让所有者拥有可读、可写和可执行权限,其余任何用户都只有可读和可写权限。

1
2
3
4
5
# 写法一
chmod u=rwx,go=rw demo.py

# 写法二
chmod 766 demo.py

至于为什么数字表示法会用 4,2,14,2,1,是因为 4,2,14,2,1 刚好对应了二进制的 001,010,100001, 010, 100,三者的组合可以完美的表示出 [0,7][0,7] 范围内的任何一个数。

默认权限 umask

1
umask
  • -S 显示字符型默认权限

直接使用 umask 会显示 44 位八进制数,第一位是当前用户的 uiduid,后三位分别表示当前用户创建文件时的默认权限的补,例如 00220022 表示当前用户 uiduid00,创建的文件/目录默认权限为 777022=755777-022=755

可能是出于安全考虑,文件默认不允许拥有可执行权限,因此如果 umask 显示为 00220022,则创建的文件默认权限为 644644,即每一位都 1-1 以确保是偶数。

一、添加 4 个用户:alice、bob、john、mike

首先需要确保当前是 root 用户,使用 su root 切换到 root 用户。然后在创建用户时同时创建该用户对应的目录:

1
2
3
4
useradd -d /home/alice -m alice
useradd -d /home/bob -m bob
useradd -d /home/john -m john
useradd -d /home/mike -m mike

添加 4 个用户

二、为 alice 设置密码

1
passwd alice

为 alice 设置密码

三、创建用户组 workgroup 并将 alice、bob、john 加入

  • 创建用户组:

    1
    groupadd workgroup
  • 添加到新组:

    1
    2
    3
    usermod -a -G workgroup alice
    usermod -a -G workgroup bob
    usermod -a -G workgroup john
    • -a:是 --append 的缩写,表示将用户添加到一个组,而不会移除她已有的其他组。这个选项必须与 -G 一起使用
    • -G:指定要添加用户的附加组(即用户可以属于多个组),这里是 workgroup
  • 将 workgroup 作为各自的主组:

    1
    2
    3
    usermod -g workgroup alice
    usermod -g workgroup bob
    usermod -g workgroup john
    • -g:用于指定用户的主组(primary group)。主组是当用户创建文件或目录时默认分配的组

创建用户组 workgroup 并将 alice、bob、john 加入

四、创建 /home/work 目录并将其属主改为 alice,属组改为 workgroup

1
2
3
4
5
6
7
8
# 创建目录
mkdir work

# 修改属主和属组
chown alice:workgroup work

# 或者
chown alice.workgroup work

创建 /home/work 目录并将其属主改为 alice,属组改为 workgroup

五、修改 work 目录的权限

使得属组内的用户对该目录具有所有权限,属组外的用户对该目录没有任何权限。

1
2
3
4
5
# 写法一
chmod ug+rwx,o-rwx work

# 写法二
chmod 770 work

修改 work 目录的权限

六、权限功能测试

以 bob 用户身份在 work 目录下创建 bob.txt 文件。可以看到符合默认创建文件的权限格式 644644

以 bob 用户在 work 下创建 bob.txt 文件

同组用户与不同组用户关于「目录/文件」的 rw 权限测试。

  • 关于 770770 目录。由于 work 目录被 bob 创建时权限设置为了 770770,bob 用户与 john 用户属于同一个组 workgroup,因此 john 因为 g=7g=7 可以进入 work 目录进行操作,而 bob 用户与 mike 用户不属于同一个组,因此 mike 因为 o=0o=0 无法进入 work 目录,更不用说查看或者修改 work 目录中的文件了。
  • 关于 644644 文件。现在 john 由于 770770 中的第二个 77 进入了 work 目录。由文件默认的 644644 权限可以知道:john 因为第一个 44 可以读文件,但是不可以写文件,因此如下图所示,可以执行 cat 查看文件内容,但是不可以执行 echo 编辑文件内容。至于 mike,可以看到无论起始是否在 work 目录,都没有权限 cd 到 work 目录或者 ls 查看 work 目录中的内容。

权限测试

进程管理与调试(实验3)

在熟悉了 bash shell 的基本命令以及 Linux 中用户与权限管理的基本概念后,我们就可以开始尝试管理 Linux 中的程序了。当然每一个程序在开始运行后都会成为一个或多个进程,因此接下来简单介绍一下 Linux 的进程管理。最后再通过调试一个 C 程序来熟悉 GNU 调试工具 gdb (GNU Debugger) 的使用。

进程管理

查看进程状态 ps

1
ps

动态查看进程状态 top

1
top

杀死某个进程 kill

1
kill -9 <PID>

一、编写一个 shell 程序 badproc.sh 使其不断循环

1
2
3
4
5
6
7
8
#! /bin/bash
while echo "I'm making files!"
do
mkdir adir
cd adir
touch afile
sleep 10s
done

编写 sh 文件

二、为 badproc.sh 增加可执行权限

1
chmod u+x badproc.sh

增加可执行权限

三、在后台执行 badproc.sh

1
./badproc.sh &
  • & 表示后台执行

后台执行

四、利用 ps 命令查看其进程号

1
ps aux | grep badproc

查看进程号

五、利用 kill 命令杀死该进程

1
kill -9 <PID>

杀死该进程

六、删除 badproc.sh 程序运行时创建的目录和文件

删除目录和文件

gdb 调试

基本命令参考 gdb调试的基本使用。常用的如下:

开始运行

1
r
  • r 即 run

设置断点

1
break <line>
  • line 即行号

运行到下一个断点

1
c
  • c 即 continue

一、创建 fork.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
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>

int main() {
/* fork another process */
pid_t pid;
pid = fork();

if (pid < 0) {
/* error occurred */
fprintf(stderr, "Fork Failed");
exit(-1);
} else if (pid == 0) {
/* child process */
printf("This is child process, pid=%d\n", getpid());
execlp("/bin/ls", "ls", NULL);
printf("Child process finished\n"); /*这句话不会被打印,除非execlp调用未成功*/
} else {
/* parent process */
/* parent will wait for the child to complete */
printf("This is parent process, pid=%d\n", getpid());
wait (NULL);
printf ("Child Complete\n");
exit(0);
}
}

创建文件

这段程序首先通过调用 fork() 函数创建一个子进程,并通过 pid 信息来判断当前进程是父进程还是子进程。在并发的逻辑下,执行哪一个进程的逻辑是未知的。

二、编译运行 fork.c 文件

编译运行

从上述运行结果可以看出:并发时,首先执行父进程的逻辑,然后才执行子进程的逻辑。

三、gdb 调试

在 fork 创建子进程后追踪子进程:

1
2
3
gdb fork
set follow-fork-mode child
catch exec

追踪子进程

运行到第一个断点时分别观察父进程 1510168 和子进程 1510171:

父进程 1510168

子进程 1510171

运行到第二个断点时观察子进程 1510171:

运行到第二个断点时观察子进程 1510171

从上述子进程的追踪结果可以看出,在父进程结束之后,子进程成功执行了 pid==0 的逻辑并调用 ls 工具。

Linux 编程(实验4)

工具扩展

tree

目录可视化工具。

下载地址:Tree for Windows (sourceforge.net)open in new window,下载 Binaries 的 Zip 文件。解压完成后,将 bin 目录下的 tree.exe 复制到 Git Bash 安装路径下的 usr/bin 文件夹下,即可使用 tree 命令。

基本命令格式:tree [-option] [dir]

  • 显示中文:-N。如果中文名是中文,不加 -N 有些电脑上是乱码的。
  • 选择展示的层级:-L [n]
  • 只显示文件夹:-d
  • 区分文件夹、普通文件、可执行文件:-FCC 是加上颜色。
  • 起别名:alias tree='tree -FCN'
  • 输出目录结构到文件: tree -L 2 -I '*.js|node_modules|*.md|*.json|*.css|*.ht' > tree.txt

wget

url 下载工具。

下载地址:Windows binaries of GNU Wget,选择合适的版本和架构包进行下载。将 wget.exe 移动到 Git Bash 安装路径下的 usr/bin 文件夹下,即可使用 wget 命令。

基本命令格式:wget [url]

  • 指定文件名:-O
  • 指定目录:-P
  • 下载多个文件:wget -i [url.txt]
  • 断点续传:wget -c -t [n] [url]n 代表尝试的次数,0 代表一直尝试。
  • 后台执行:wget -b [url]。执行该命令的回显信息都会自动存储在 wget.log 文件中。
  • 下载一个网站的所有图片、视频和 pdf 文件:wget -r -A.pdf url

参考引用

在 Windows 中使用 Bash shell

Linux 用户权限信息

gdb调试的基本使用

]]> + Shell 基础

前言

尝试在 Windows11 上使用 wget 通过一个 url 下载一个 js 静态文件。但是发现本地 bash 使用 wget 命令报错:command not found。于是借 wget 之名展开”古老“的 shell 之旅。

Shell 是什么?我们用 GLM4 总结一下:

Shell 的准确定义是:在操作系统中,Shell 是一个程序,它提供了一个用户界面,允许用户通过命令行输入指令来与操作系统交互。它充当用户与操作系统内核之间的中介,处理用户的输入,解释这些输入为操作系统可以理解和执行的操作,并返回执行结果。

也就是说 shell 是一个命令解释器。可以将其理解为人机交互的中间层,通过解析字符串命令与操作系统交互,进而完成系统资源的调用与处理。可以将「解析命令并调用系统资源完成任务」这个过程简单地理解为:shell 解析输入 \to shell 调用对应的 C 函数向 OS 发起请求 API\xrightarrow[]{\text{API}} OS 接受请求并执行相关底层操作 \to OS 返回执行结果给 C 函数 \to shell 接受 C 函数返回结果并显示提示信息。

为什么用 Bash Shell?常见的命令解释器有:Bash (Bourne Again Shell)、Sh (Bourne Shell)、Zsh (Z Shell) 等。其中 Bash 是最流行的跨平台 Shell 之一。虽然它是 GNU/Linux 和 macOS 的默认 Shell,但也可以在 Windows 上通过 Windows Subsystem for Linux (WSL) 或第三方工具如 Git Bash 来运行。

文章标题为什么不叫 GNU/Linux 基础?在我看来,GNU/Linux 基础的内容更多应当偏向于讲解 GNU/Linux 的理论知识,然而目前主流的讲解 GNU/Linux 基础的内容均偏向于指导大家如何使用 shell 与操作系统交互,那这与 python 等解释型编程语言有什么区别?python 是用 python 解释器与操作系统交互,相关的教程默认都叫「Python 基础」,shell 是用 shell 解释器与操作系统交互,那为什么不叫「Shell 基础」呢?

注:本文基于 Windows11 的 Git Bash 命令解释器配合 Ubuntu22.04 的 Bash 命令解释器展开。两个平台的 Bash 版本信息如下:

Windows11 Git Bash Version Info:

1
2
3
4
5
6
7
$ bash --version
GNU bash, version 5.2.26(1)-release (x86_64-pc-msys)
Copyright (C) 2022 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>

This is free software; you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.

GNU/Linux Bash Version Info:

1
2
3
4
5
6
7
root@dwj2:~# bash --version
GNU bash,版本 5.1.16(1)-release (x86_64-pc-linux-gnu)
Copyright (C) 2020 Free Software Foundation, Inc.
许可证 GPLv3+:GNU GPL 许可证第三版或者更新版本 <http://gnu.org/licenses/gpl.html>

本软件是自由软件,您可以自由地更改和重新发布。
在法律许可的情况下特此明示,本软件不提供任何担保。

Shell 基本命令

在开始之前,我们可以使用 echo $SHELL 命令查看当前的命令解释器是什么:

1
2
root@dwj2:~# echo $SHELL
/bin/bash

常用命令

改变目录 cd

1
cd ../

../ 表示上一级,./ 表示当前一级(也可以不写),/ 表示从根目录开始。

打印目录内容 ls

1
ls
  • -l 参数即 long listing format,表示打印详细信息。
  • -h 参数即 human-readable,会使得结果更加可读,例如占用存储空间加上单位等等。

打印当前路径 pwd

1
pwd

打印当前用户名

1
whoami

创建文件夹 mkdir

1
mkdir <FolderName>

创建文件 touch

1
touch <FileName>

复制 cp

1
cp [option] <source> <target>
  • -r 表示递归复制,-i 用来当出现重名文件时进行提示。
  • source 表示被拷贝的资源。
  • target 表示拷贝后的资源名称或者路径。

移动 mv

1
mv [option] <source> <target>
  • -i 用来当出现重名文件时进行提示。
  • source 表示被移动的资源。
  • target 表示移动后的资源名称或者路径(可以以此进行重命名)。

删除 rm

1
rm [option] <source>
  • -i 需要一个一个确认,-f 表示强制删除,-r 表示递归删除。

打印 echo

1
echo "hello"
  • 将 echo 后面紧跟着的内容打印到命令行解释器中。可以用来查看环境变量的具体值。

  • 也可以配合输出重定向符号 > 将信息打印到文件中实现创建新文件的作用。例如 echo "hello" > file.txt 用于创建 file.txt 文件并且在其中写下 hello 信息。

查看 cat

1
cat [option] <source>
  • -n--number 表示将 source 中的内容打印出来的同时带上行号。
  • 也可以配合输出重定位符号 > 将信息输出到文件中。例如 cat -n a.txt > b.txt 表示将 a.txt 文件中的内容带上行号输出到 b.txt 文件中。

分页查看 more

1
more <source>

cat 类似,只不过可以分页展示。按空格键下一页,b 键上一页,q 键退出。

可以配合管道符 | (将左边的输出作为右边的输入) 与别的命令组合从而实现分页展示,例如 tree | more 表示分页打印文件目录。

输出重定向

标准输出(stdout)默认是显示器。

> 表示创建或覆盖,>> 表示追加。

重要命令

查找 find *

1
find <path> <expression>
  • -maxdepth <num>, -mindepth <num>: 最大、最小搜索深度。

匹配 grep *

1
grep [option] <pattern> <source>

使用正则表达式在指定文件中进行模式匹配。

  • -n 显示行号,-i 忽略大小写,-r 递归搜索,-c 打印匹配数量。

用户与权限管理

在 Windows 中,我们对用户和权限的概念接触的并不多,因为很多东西都默认设置好了。但是在 GNU/Linux 中,很多文件的权限都需要自己配置和定义,因此「用户与权限管理」的操作方法十分重要。我们从现象入手逐个进行讲解。

首先以 root 用户身份登录并进入 /opt/OS/task2/ 目录,然后创建一个测试文件 root_file.txt 和一个测试文件夹 root_folder。使用 ls -l 命令列出当前目录下所有文件的详细信息:

root 用户创建的文件和文件夹

可以看到一共有 66 列信息,从左到右依次为:用户访问权限、与文件链接的个数、文件属主、文件属组、文件大小、最后修改日期、文件/目录名。252-5 列的信息都很显然,第 11 列信息一共有 1010 个字符,其中第 11 个字符表示当前文件的类型,共有如下几种:

文件类型符号
普通文件-
目录d
字符设备文件c
块设备文件b
符号链接l
本地域套接口s
有名管道p

2102-1099 个字符分 33 个一组,分别表示「属主 user 权限、属组 group 权限、其他用户 other 权限」,下面分别介绍「用户和组」以及「权限管理」两个概念。

关于用户和组

首先我们需要明确用户和组这两个概念的定义:

  • 文件用户(user/u):文件的拥有者。
  • 所属组(group/g):与该文件关联的用户组,组内成员享有特定的权限。
  • 其他用户(others/o):系统中不属于拥有者或组的其他用户。

举个例子。对于当前用户 now_user 以及当前用户所在的组 now_group,同组用户 adj_user 和其他用户 other_user 可以形象的理解为以下的集合关系:

graph TB    subgraph ag[another_group]    other_user1    other_user2    end    subgraph ng[now_group]    now_user    adj_user    end

关于权限管理

一共有 3 种权限,如下表所示:

文件目录
r可查看文件能列出目录下内容
w可修改文件能在目录中创建、删除和重命名文件
x可执行文件能进入目录

那么我们平时看到的关于权限还有数字的配置,是怎么回事呢?其实是对上述字符配置的八进制数字化。读 rr 对应 44,写 ww 对应 22,可执行 xx 对应 11,例如如果一个文件对于所有用户都拥有可读、可写、可执行权限,那么就是 rwxrwxrwx,对应到数字就是 777777

相关命令

下面罗列一些用户与权限管理相关的命令,打 * 表示重要命令。

提升权限 sudo

1
sudo ...

使用 sudo 前缀可以使得当前用户的权限提升到 root 权限,如果是 root 用户则无需添加。

查看当前用户 whoami

1
whoami

打印当前用户名。

创建用户 useradd

1
useradd <username>

添加用户。

删除用户 userdel

1
userdel <username>

删除用户。

  • -r 表示同时删除数据信息。

修改用户信息 usermod

1
usermod
  • 使用 -h 参数查看所有用法。

修改用户密码 passwd

1
passwd <username>

切换用户 su

1
su <username>

添加 - 参数则直接进入 /home/<username>/ 目录(如果有的话)。

查看当前用户所属组 groups

1
groups

创建用户组 groupadd

1
groupadd

删除用户组 groupdel

1
groupdel

改变属主 chown *

1
chown <user> <filename>

将指定文件 filename 更改属主为 user。

改变属组 chgrp *

1
chgrp <group> <filename>

将指定文件 filename 更改属组为 group。

改变权限 chmod *

1
chmod <option> <filename>

将文件 filename 更改所有用户对应的权限。举个例子就知道了:让 demo.py 文件只能让所有者拥有可读、可写和可执行权限,其余任何用户都只有可读和可写权限。

1
2
3
4
5
# 写法一
chmod u=rwx,go=rw demo.py

# 写法二
chmod 766 demo.py

至于为什么数字表示法会用 4,2,14,2,1,是因为 4,2,14,2,1 刚好对应了二进制的 001,010,100001, 010, 100,三者的组合可以完美的表示出 [0,7][0,7] 范围内的任何一个数。

默认权限 umask

1
umask
  • -S 显示字符型默认权限

直接使用 umask 会显示 44 位八进制数,第一位是当前用户的 uiduid,后三位分别表示当前用户创建文件时的默认权限的补,例如 00220022 表示当前用户 uiduid00,创建的文件/目录默认权限为 777022=755777-022=755

可能是出于安全考虑,文件默认不允许拥有可执行权限,因此如果 umask 显示为 00220022,则创建的文件默认权限为 644644,即每一位都 1-1 以确保是偶数。

一、添加 4 个用户:alice、bob、john、mike

首先需要确保当前是 root 用户,使用 su root 切换到 root 用户。然后在创建用户时同时创建该用户对应的目录:

1
2
3
4
useradd -d /home/alice -m alice
useradd -d /home/bob -m bob
useradd -d /home/john -m john
useradd -d /home/mike -m mike

添加 4 个用户

二、为 alice 设置密码

1
passwd alice

为 alice 设置密码

三、创建用户组 workgroup 并将 alice、bob、john 加入

  • 创建用户组:

    1
    groupadd workgroup
  • 添加到新组:

    1
    2
    3
    usermod -a -G workgroup alice
    usermod -a -G workgroup bob
    usermod -a -G workgroup john
    • -a:是 --append 的缩写,表示将用户添加到一个组,而不会移除她已有的其他组。这个选项必须与 -G 一起使用
    • -G:指定要添加用户的附加组(即用户可以属于多个组),这里是 workgroup
  • 将 workgroup 作为各自的主组:

    1
    2
    3
    usermod -g workgroup alice
    usermod -g workgroup bob
    usermod -g workgroup john
    • -g:用于指定用户的主组(primary group)。主组是当用户创建文件或目录时默认分配的组

创建用户组 workgroup 并将 alice、bob、john 加入

四、创建 /home/work 目录并将其属主改为 alice,属组改为 workgroup

1
2
3
4
5
6
7
8
# 创建目录
mkdir work

# 修改属主和属组
chown alice:workgroup work

# 或者
chown alice.workgroup work

创建 /home/work 目录并将其属主改为 alice,属组改为 workgroup

五、修改 work 目录的权限

使得属组内的用户对该目录具有所有权限,属组外的用户对该目录没有任何权限。

1
2
3
4
5
# 写法一
chmod ug+rwx,o-rwx work

# 写法二
chmod 770 work

修改 work 目录的权限

六、权限功能测试

以 bob 用户身份在 work 目录下创建 bob.txt 文件。可以看到符合默认创建文件的权限格式 644644

以 bob 用户在 work 下创建 bob.txt 文件

同组用户与不同组用户关于「目录/文件」的 rw 权限测试。

  • 关于 770770 目录。由于 work 目录被 bob 创建时权限设置为了 770770,bob 用户与 john 用户属于同一个组 workgroup,因此 john 因为 g=7g=7 可以进入 work 目录进行操作,而 bob 用户与 mike 用户不属于同一个组,因此 mike 因为 o=0o=0 无法进入 work 目录,更不用说查看或者修改 work 目录中的文件了。
  • 关于 644644 文件。现在 john 由于 770770 中的第二个 77 进入了 work 目录。由文件默认的 644644 权限可以知道:john 因为第一个 44 可以读文件,但是不可以写文件,因此如下图所示,可以执行 cat 查看文件内容,但是不可以执行 echo 编辑文件内容。至于 mike,可以看到无论起始是否在 work 目录,都没有权限 cd 到 work 目录或者 ls 查看 work 目录中的内容。

权限测试

进程管理与调试

在熟悉了 bash shell 的基本命令以及 GNU/Linux 中用户与权限管理的基本概念后,我们就可以开始尝试管理 GNU/Linux 中的程序了。当然每一个程序在开始运行后都会成为一个或多个进程,因此接下来简单介绍一下 GNU/Linux 的进程管理。最后再通过调试一个 C 程序来熟悉 GNU 调试工具 gdb (GNU Debugger) 的使用。

进程管理相关命令

查看进程状态 ps

1
ps

动态查看进程状态 top

1
top

杀死某个进程 kill

1
kill -9 <PID>

一、编写一个 shell 程序 badproc.sh 使其不断循环

1
2
3
4
5
6
7
8
#! /bin/bash
while echo "I'm making files!"
do
mkdir adir
cd adir
touch afile
sleep 10s
done

编写 sh 文件

二、为 badproc.sh 增加可执行权限

1
chmod u+x badproc.sh

增加可执行权限

三、在后台执行 badproc.sh

1
./badproc.sh &
  • & 表示后台执行

后台执行

四、利用 ps 命令查看其进程号

1
ps aux | grep badproc

查看进程号

五、利用 kill 命令杀死该进程

1
kill -9 <PID>

杀死该进程

六、删除 badproc.sh 程序运行时创建的目录和文件

删除目录和文件

gdb 调试相关命令

基本命令参考 GNU OF GDB 官网:https://www.gnu.org/software/gdb/。常用的如下:

开始运行

1
r
  • r 即 run

设置断点

1
break <line>
  • line 即行号

运行到下一个断点

1
c
  • c 即 continue

一、创建 fork.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
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>

int main() {
/* fork another process */
pid_t pid;
pid = fork();

if (pid < 0) {
/* error occurred */
fprintf(stderr, "Fork Failed");
exit(-1);
} else if (pid == 0) {
/* child process */
printf("This is child process, pid=%d\n", getpid());
execlp("/bin/ls", "ls", NULL);
printf("Child process finished\n"); /*这句话不会被打印,除非execlp调用未成功*/
} else {
/* parent process */
/* parent will wait for the child to complete */
printf("This is parent process, pid=%d\n", getpid());
wait (NULL);
printf ("Child Complete\n");
exit(0);
}
}

创建文件

这段程序首先通过调用 fork() 函数创建一个子进程,并通过 pid 信息来判断当前进程是父进程还是子进程。在并发的逻辑下,执行哪一个进程的逻辑是未知的。

二、编译运行 fork.c 文件

编译运行

从上述运行结果可以看出:并发时,首先执行父进程的逻辑,然后才执行子进程的逻辑。

三、gdb 调试

在 fork 创建子进程后追踪子进程:

1
2
3
gdb fork
set follow-fork-mode child
catch exec

追踪子进程

运行到第一个断点时分别观察父进程 1510168 和子进程 1510171:

父进程 1510168

子进程 1510171

运行到第二个断点时观察子进程 1510171:

运行到第二个断点时观察子进程 1510171

从上述子进程的追踪结果可以看出,在父进程结束之后,子进程成功执行了 pid == 0 的逻辑并开始调用 ls 工具。

GNU/Linux 编程

这里的 GNU/Linux 编程对应的高级语言是 C/C++ 编程语言。本部分主要是为了熟悉 C/C++ 编程中的 静态链接动态链接 逻辑。

GCC 基础

相比于在 Windows 进行 C/C++ 编程时需要自己额外安装编译器集合 MSVC (Microsoft Visual C++) 或 MinGW (Minimalist GNU for Windows),GNU/Linux 发行版 Ubuntu22.04 已经默认配置好了编译器集合 GCC (GNU Compiler Collection),我们可以利用 GCC 提供的前端工具 gcc 等快捷地使用编译器集合中的所有工具。具体命令可以参考 GCC 官方在线文档:https://gcc.gnu.org/onlinedocs/

我们可以使用 gcc --version 命令查看当前的 GCC 版本:

1
2
3
4
5
root@dwj2:/opt/OS/task4# gcc --version
gcc (Ubuntu 11.4.0-1ubuntu1~22.04) 11.4.0
Copyright (C) 2021 Free Software Foundation, Inc.
This is free software; see the source for copying conditions. There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.

因此我们选择版本最相近的手册 gcc-11.5.0 进行阅读。对于最基本的编译操作和理论,已经在 计算机系统基础 课程中有所学习,就不重复造轮子了,常见的指令直接跳转阅读即可。

环境变量

对于当前路径下链接出来的可执行文件 demo,为什么 demo 无法正常执行,./demo 就可以正常执行?如下图所示:

demo 无法正常执行 VS ./demo 可以正常执行

根本原因是 bash 默认执行 PATH 环境变量下的可执行文件,显然上述的 demo 可执行文件并不在 PATH 对应的路径下,那么 PATH 路径都有哪些呢?我们使用 echo $PATH | tr ':' '\n' 打印出来:

1
2
3
4
5
6
7
8
root@dwj2:/opt/OS/task4# echo $PATH | tr ':' '\n'
/usr/local/sbin
/usr/local/bin
/usr/sbin
/usr/bin
/usr/games
/usr/local/games
/snap/bin

能不能使用 demo 运行呢?有很多解决办法,但根本逻辑都是将当前路径加入到 PATH 环境变量。下面补充几个 gcc 和 g++ (编译 C++ 用) 相关的环境变量:

  • 头文件搜索路径
    • C_INCLUDE_PATH: gcc 找头文件的路径。
    • CPLUS_INCLUDE_PATH: g++ 找头文件的路径。
  • 库文件搜索路径
    • LD_LIBRARY_PATH: 找动态链接库的路径。
    • LIBRARY_PATH: 找静态链接库的路径。

gcc 选项

在计算机系统基础中已经学习到了,C/C++ 最基本的编译链就是 -E-S-c-o,每一个参数都包含前面所有的参数。下面主要讲讲 -I<dir>-L<dir>-l<name> 三个参数:

  • -I<dir> 顾名思义就是「头文件导入」的搜索目录。例如下面的编译语句:

    1
    gcc –I/opt/OS/task4/include demo.c

    注意:当我们不使用 -o 参数指定 outfile 的名称时,默认是 a.out,如下图所示,其中 demo 可执行文件是之前用来输出 'hello' 的:

    -I

  • -L<dir> 顾名思义就是「库文件连接」搜索目录。例如下面的编译语句:

    1
    gcc -o x11fred -L/usr/openwin/lib x11fred.c
  • -l<name> 比较有意思,就是直接制定了库文件是哪一个。正因为有了这样的用法,我们在给库文件 (.a 表示静态库文件,.so 表示动态库文件) 起名时,就只能起 lib<name>.alib<name>.so。例如下面的编译语句:

    1
    2
    3
    gcc -o fred -lm fred.c
    # 等价于
    gcc –o fred /usr/lib/libm.a fred.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
// addvec.c
void addvec(int* x, int* y, int* z, int n) {
for(int i = 0; i < n ; i++) {
z[i] = x[i] + y[i];
}
}

// multvec.c
void multvec(int* x, int* y, int* z, int n) {
for(int i = 0; i < n ; i++) {
z[i] = x[i] * y[i];
}
}

// vector.h
void addvec(int* x, int* y, int* z, int n);
void multvec(int* x, int* y, int* z, int n);

// main.c
#include <stdio.h>
#include "vector.h"
int x[2] = {1, 2}, y[2] = {3, 4}, z[2];
int main() {
addvec(x, y, z, 2);
printf("z = [%d, %d]\n", z[0], z[1]);
return 0;
}

生成静态库文件 libvector.a 并链接至可执行文件 p1 中:

1
2
3
4
5
6
7
8
# 将两个自定义库函数编译为可重定位目标文件 addvec.o 和 multvec.o
gcc -c addvec.c multvec.c

# 将两个可重定位目标文件打包成静态库文件 libvector.a
ar crv libvector.a addvec.o multvec.o

# 生成静态链接的可执行文件 p1
gcc -static -o p1 main.c -L. -lvector

生成动态库文件 libvector.so 并链接至可执行文件 p2 中:

1
2
3
4
5
6
7
8
# 将两个自定义库函数编译为动态库文件 libvector.so
gcc -shared -o libvector.so addvec.c multvec.c

# 生成动态链接的可执行文件 p2
gcc -o p2 main.c -L. -lvector

# 使用 ./p2 执行之前需要明确一下动态库文件的链接搜索路径,否则会找不到动态库文件
export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:.

最后我们查看一下 p1 和 p2 详细信息,如下图所示。显然静态链接的可执行文件 p1 占用的存储空间远大于动态连接的可执行文件 p2。

p1 和 p2 详细信息

工具扩展

tree

目录可视化工具。

下载地址:Tree for Windows (sourceforge.net)open in new window,下载 Binaries 的 Zip 文件。解压完成后,将 bin 目录下的 tree.exe 复制到 Git Bash 安装路径下的 usr/bin 文件夹下,即可使用 tree 命令。

基本命令格式:tree [-option] [dir]

  • 显示中文:-N。如果中文名是中文,不加 -N 有些电脑上是乱码的。
  • 选择展示的层级:-L [n]
  • 只显示文件夹:-d
  • 区分文件夹、普通文件、可执行文件:-FCC 是加上颜色。
  • 起别名:alias tree='tree -FCN'
  • 输出目录结构到文件: tree -L 2 -I '*.js|node_modules|*.md|*.json|*.css|*.ht' > tree.txt

wget

url 下载工具。

下载地址:Windows binaries of GNU Wget,选择合适的版本和架构包进行下载。将 wget.exe 移动到 Git Bash 安装路径下的 usr/bin 文件夹下,即可使用 wget 命令。

基本命令格式:wget [url]

  • 指定文件名:-O
  • 指定目录:-P
  • 下载多个文件:wget -i [url.txt]
  • 断点续传:wget -c -t [n] [url]n 代表尝试的次数,0 代表一直尝试。
  • 后台执行:wget -b [url]。执行该命令的回显信息都会自动存储在 wget.log 文件中。
  • 下载一个网站的所有图片、视频和 pdf 文件:wget -r -A.pdf url

参考引用

在 Windows 中使用 Bash shell

Linux 用户权限信息

gdb 调试的基本使用

]]> @@ -472,7 +472,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 章重复,详见 https://blog.dwj601.cn/GPA/4th-term/SysBasic/#第1章-计算机系统概述

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

二、数据的机器级表示

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

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

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

三、运算方法和运算部件

本章我们讲讲 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)。如何利用已有的运算部件来巧妙地设计算法以高效地实现我们常见的数学运算呢?让我们一探究竟!

整数加减运算

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

原码乘法运算

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

  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,数值位为 1110×11011110 \times 1101 的原码乘法运算结果 1011011010110110

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

补码乘法运算

怎么算的?如何在已知 [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[Pi]=[Pi1]1+[(yi2yi1)X]1\begin{align}[P_{i}]_{\text{补}} &= [P_{i-1} + (y_{i-2}-y_{i-1})X]_{\text{补}} \gg 1 \\[P_{i}]_{\text{补}} &= [P_{i-1}]_{\text{补}} \gg 1 + [(y_{i-2}-y_{i-1})X]_{\text{补}} \gg 1\end{align}

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

算例

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

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

整数除法运算

四、指令系统

五、中央处理器

六、指令流水线

七、存储器层次结构

7.4 存储器的数据校验

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

数据校验流程图

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

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

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

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

八、系统互连及输入输出组织

九、并行处理系统 *

不做要求,略。

]]> + 计算机组成原理

前言

学科地位:

主讲教师学分配额学科类别
闫文珠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 章重复,详见 https://blog.dwj601.cn/GPA/4th-term/SysBasic/#第1章-计算机系统概述

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

二、数据的机器级表示

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

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

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

三、运算方法和运算部件

本章我们讲讲 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)。如何利用已有的运算部件来巧妙地设计算法以高效地实现我们常见的数学运算呢?让我们一探究竟!

整数加减运算

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

原码乘法运算

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

  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,数值位为 1110×11011110 \times 1101 的原码乘法运算结果 1011011010110110

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

补码乘法运算

怎么算的?如何在已知 [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[Pi]=[Pi1]1+[(yi2yi1)X]1\begin{align}[P_{i}] _{\text{补}} &= [P_{i-1} + (y_{i-2}-y_{i-1})X]_{\text{补}} \gg 1 \\[P_{i}] _{\text{补}} &= [P_{i-1}]_{\text{补}} \gg 1 + [(y_{i-2}-y_{i-1})X]_{\text{补}} \gg 1\end{align}

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

算例

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

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

整数除法运算

不做要求,略。

四、指令系统

五、中央处理器

六、指令流水线

七、存储器

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

基本概念

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

缓存

内存

外存

虚存

存储器的数据校验

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

数据校验流程图

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

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

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

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

八、系统互连及输入输出组织

九、并行处理系统 *

不做要求,略。

]]> @@ -947,11 +947,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。。。。你我皆是局内人,祝好。

]]> + 机器学习与模式识别

前言

学科地位:

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

成绩组成:

理论课:

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

实验课:

19次实验(五级制)
100%

教材情况:

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

学习资源:

实验平台:

本地路径:

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

第1章 绪论

1.1 引言

pass

1.2 基本术语

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

1.3 假设空间

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

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

1.4 归纳偏好

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

1.5 发展历程

pass

1.6 应用现状

pass

第2章 模型评估与选择

2.1 经验误差与过拟合

概念辨析

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

误差训练曲线

过拟合解决方法

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

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

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

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

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

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

    正则化有以下形式:

    1. 一般式:

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

    2. L1 正则化:

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

    3. L2 正则化:

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

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

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

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

欠拟合解决方法

  • 决策树:拓展分支

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

2.2 评估方法

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

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

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

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

2.3 性能度量

2.3.1 回归任务

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

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

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

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

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

2.3.2 分类任务

混淆矩阵

混淆矩阵 - 图例

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

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

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

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

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

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

P-R 曲线

P-R 曲线趋势图

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

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

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

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

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

ROC 曲线与 AUC

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

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

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

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

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

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

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

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

    损失函数计算公式

2.4 比较检验

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

两个学习器性能比较:

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

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

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

    列联表

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

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

2.5 偏差与方差

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

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

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

2.5.1 偏差-方差分解

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

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

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

偏差-方差分解的结论:

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

偏差-方差分解结论推导

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

2.5.2 偏差-方差窘境

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

偏差-方差窘境 示意图

第3章 线性模型

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

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

3.1 基本形式

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

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

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

3.2 线性回归

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

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

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

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

3.2.1 一元线性回归

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

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

闭式解推导过程:

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

3.2.2 多元线性回归

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

3.2.3 广义线性回归

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

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

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

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

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

线性模型拟合非线性数据

3.2.4 其他线性回归

  • 支持向量机回归

  • 决策树回归

  • 随机森林回归

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

    LASSO 回归

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

    ElasticNet 回归

  • XGBoost 回归

3.3 对数几率回归

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

3.3.1 阈值函数的选择

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

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

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

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

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

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

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

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

3.3.2 模型参数的求解

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

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

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

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

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

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

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

同时,显然有

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

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

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

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

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

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

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

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

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

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

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

目标函数:

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

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

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

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

一阶导

二阶导

3.4 线性判别分析

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

3.5 多分类学习

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

3.5.1 一对一

一对一

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

3.5.2 一对其余

一对其余

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

3.5.3 多对多

多对多

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

3.5.4 softmax

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

3.6 类别不平衡问题

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

3.6.1 阈值移动

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

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

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

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

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

3.6.2 欠采样

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

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

典型的算法是:EasyEnsemble

3.6.3 过采样

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

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

典型的算法是:SMOTE

第4章 决策树

4.1 基本流程

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

决策树结构解读:

决策树结构

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

决策树生成过程:

决策树算法伪代码

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

4.2 划分选择

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

4.2.1 信息增益

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

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

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

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

代表算法:ID3

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

4.2.2 增益率

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

我们定义增益率为:

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

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

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

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

代表算法:C4.5

4.2.3 基尼指数

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

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

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

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

代表算法:CART

4.3 剪枝处理

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

4.3.1 预剪枝

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

4.3.2 后剪枝

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

4.3.3 区别与联系(补)

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

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

4.4 连续与缺失值

4.4.1 连续值处理

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

4.4.2 缺失值处理

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

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

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

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

4.5 多变量决策树

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

合适的线性分类器

第5章 神经网络

5.1 神经元模型

M-P 神经元模型

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

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

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

5.2 感知机与多层网络

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

5.2.1 感知机

感知机(Perceptron)

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

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

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

二维线性可分任务

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

一维线性可分任务

5.2.2 多层感知机

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

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

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

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

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

5.2.3 多层前馈神经网络

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

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

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

5.3 误差逆传播算法

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

5.3.1 模型参数

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

单隐层神经网络

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

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

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

5.3.2 参数推导

确定损失函数。

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

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

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

确定迭代修正量。

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

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

  • 其中,修正量分别为:

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

公式表示:

公式表示

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

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

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

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

5.3.3 算法流程

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

BP 神经网络算法流程

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

第6章 支持向量机

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

本章知识点逻辑链:

支持向量机知识点关系图

6.1 间隔与支持向量

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

间隔与支持向量

我们定义超平面为:

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

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

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

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

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

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

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

可以等价转化为:

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

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

6.2 对偶问题

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

对偶转化推导 - 1

对偶转化推导 - 2

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

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

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

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

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

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

对于上述互补松弛性:

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

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

6.3 核函数

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

常用核函数

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

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

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

6.4 软间隔与正则化

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

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

loptionl_{\text{option}}

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

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

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

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

6.5 支持向量回归

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

原始问题:

原始问题

约束条件

对偶问题:

对偶问题

KKT 条件:

KKT 条件

预测模型:

预测模型

6.6 核方法

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

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

第7章 贝叶斯分类器

7.1 贝叶斯决策论

pass

7.2 极大似然估计

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

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

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

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

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

7.3 朴素贝叶斯分类器

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

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

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

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

注意点:

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

7.4 半朴素贝叶斯分类器

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

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

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

属性依赖图

如上图所示:

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

7.5 贝叶斯网

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

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

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

7.6 EM 算法

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

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

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

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

第8章 集成学习

8.1 个体与集成

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

多个不同的学习组件

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

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

8.2 Boosting

Boosting 算法族的逻辑:

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

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

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

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

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

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

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

代表算法:AdaBoost、GBDT、XGBoost

8.3 Bagging 与 随机森林

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

8.3.1 Bagging

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

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

8.3.2 随机森林

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

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

8.3.3 区别与联系(补)

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

提升了模型性能

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

8.4 区别与联系(补)

区别:

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

联系:

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

8.5 结合策略

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

8.5.1 平均法

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

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

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

8.5.2 投票法

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

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

8.5.3 学习法

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

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

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

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

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

8.6 多样性

8.6.1 误差-分歧分解

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

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

8.6.2 多样性度量

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

预测结果列联表

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

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

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

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

卡帕-统计量

8.6.3 多样性增强

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

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

第9章 聚类

9.1 聚类任务

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

9.2 性能度量

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

9.2.1 外部指标

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

外部指标变量

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

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

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

9.2.2 内部指标

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

内部指标变量

常见的外部指标如下:

  • DB 指数
  • Dunn 指数

9.3 距离计算

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

无序属性:VDM 距离

9.4 原型聚类

9.4.1 k 均值算法

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

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

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

k 均值算法伪代码

9.4.2 学习向量量化算法

9.4.3 高斯混合聚类算法

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

9.5 密度聚类

DBSCAN 算法

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

9.6 层次聚类

AGNES 算法

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

第10章 降维与度量学习

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

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

10.1 k 近邻学习

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

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

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

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

10.2 多维缩放降维

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

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

内积计算:

内积计算

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

新属性值计算

10.3 主成分分析降维

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

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

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

PCA 算法流程

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

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

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

步骤 1: 样本中心化

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

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

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

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

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

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

步骤 2: 计算协方差矩阵

样本的协方差矩阵为:

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

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

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

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

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

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

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

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

10.4 核化线性降维

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

10.5 流形学习降维

假定数据满足流形结构。

10.5.1 等度量映射

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

10.5.2 局部线性嵌入

pass

10.6 度量学习

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

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

马氏距离

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

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

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

10.6.1 近邻成分分析

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

10.6.2 LMNN

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

paper: Distance Metric Learning for Large Margin Nearest Neighbor Classification

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

第13章 半监督学习

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

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

三种半监督学习

13.1 未标记样本

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

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

13.2 生成式方法

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

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

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

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

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

自监督训练(补)

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

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

13.3 半监督 SVM

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

优化函数

算法图例

二分类 - 伪代码

13.4 图半监督学习

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

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

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

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

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

优化函数

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

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

多分类 - 伪代码

13.5 基于分歧的方法

多学习器协同训练。

第14章 概率图模型

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

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

14.1 隐马尔可夫模型

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

需要解决三个问题:

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

14.2 马尔科夫随机场

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

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

14.3 条件随机场

判别式方法。

14.4 学习和推断

精确推断。

14.5 近似推断

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

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

考试大纲

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

前言

学科地位:

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

成绩组成:

理论课:

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

实验课:

19次实验(五级制)
100%

教材情况:

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

学习资源:

实验平台:

本地路径:

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

第1章 绪论

1.1 引言

pass

1.2 基本术语

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

1.3 假设空间

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

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

1.4 归纳偏好

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

1.5 发展历程

pass

1.6 应用现状

pass

第2章 模型评估与选择

2.1 经验误差与过拟合

概念辨析

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

误差训练曲线

过拟合解决方法

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

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

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

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

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

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

    正则化有以下形式:

    1. 一般式:

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

    2. L1 正则化:

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

    3. L2 正则化:

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

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

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

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

欠拟合解决方法

  • 决策树:拓展分支

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

2.2 评估方法

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

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

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

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

2.3 性能度量

2.3.1 回归任务

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

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

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

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

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

2.3.2 分类任务

混淆矩阵

混淆矩阵 - 图例

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

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

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

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

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

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

P-R 曲线

P-R 曲线趋势图

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

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

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

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

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

ROC 曲线与 AUC

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

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

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

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

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

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

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

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

    损失函数计算公式

2.4 比较检验

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

两个学习器性能比较:

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

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

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

    列联表

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

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

2.5 偏差与方差

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

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

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

2.5.1 偏差-方差分解

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

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

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

偏差-方差分解的结论:

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

偏差-方差分解结论推导

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

2.5.2 偏差-方差窘境

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

偏差-方差窘境 示意图

第3章 线性模型

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

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

3.1 基本形式

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

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

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

3.2 线性回归

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

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

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

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

3.2.1 一元线性回归

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

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

闭式解推导过程:

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

3.2.2 多元线性回归

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

3.2.3 广义线性回归

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

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

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

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

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

线性模型拟合非线性数据

3.2.4 其他线性回归

  • 支持向量机回归

  • 决策树回归

  • 随机森林回归

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

    LASSO 回归

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

    ElasticNet 回归

  • XGBoost 回归

3.3 对数几率回归

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

3.3.1 阈值函数的选择

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

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

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

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

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

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

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

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

3.3.2 模型参数的求解

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

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

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

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

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

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

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

同时,显然有

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

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

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

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

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

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

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

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

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

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

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

目标函数:

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

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

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

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

一阶导

二阶导

3.4 线性判别分析

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

3.5 多分类学习

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

3.5.1 一对一

一对一

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

3.5.2 一对其余

一对其余

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

3.5.3 多对多

多对多

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

3.5.4 softmax

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

3.6 类别不平衡问题

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

3.6.1 阈值移动

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

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

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

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

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

3.6.2 欠采样

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

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

典型的算法是:EasyEnsemble

3.6.3 过采样

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

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

典型的算法是:SMOTE

第4章 决策树

4.1 基本流程

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

决策树结构解读:

决策树结构

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

决策树生成过程:

决策树算法伪代码

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

4.2 划分选择

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

4.2.1 信息增益

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

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

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

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

代表算法:ID3

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

4.2.2 增益率

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

我们定义增益率为:

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

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

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

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

代表算法:C4.5

4.2.3 基尼指数

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

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

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

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

代表算法:CART

4.3 剪枝处理

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

4.3.1 预剪枝

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

4.3.2 后剪枝

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

4.3.3 区别与联系(补)

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

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

4.4 连续与缺失值

4.4.1 连续值处理

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

4.4.2 缺失值处理

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

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

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

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

4.5 多变量决策树

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

合适的线性分类器

第5章 神经网络

5.1 神经元模型

M-P 神经元模型

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

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

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

5.2 感知机与多层网络

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

5.2.1 感知机

感知机(Perceptron)

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

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

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

二维线性可分任务

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

一维线性可分任务

5.2.2 多层感知机

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

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

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

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

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

5.2.3 多层前馈神经网络

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

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

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

5.3 误差逆传播算法

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

5.3.1 模型参数

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

单隐层神经网络

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

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

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

5.3.2 参数推导

确定损失函数。

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

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

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

确定迭代修正量。

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

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

  • 其中,修正量分别为:

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

公式表示:

公式表示

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

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

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

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

5.3.3 算法流程

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

BP 神经网络算法流程

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

第6章 支持向量机

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

本章知识点逻辑链:

支持向量机知识点关系图

6.1 间隔与支持向量

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

间隔与支持向量

我们定义超平面为:

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

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

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

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

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

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

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

可以等价转化为:

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

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

6.2 对偶问题

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

对偶转化推导 - 1

对偶转化推导 - 2

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

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

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

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

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

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

对于上述互补松弛性:

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

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

6.3 核函数

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

常用核函数

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

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

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

6.4 软间隔与正则化

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

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

loptionl_{\text{option}}

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

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

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

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

6.5 支持向量回归

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

原始问题:

原始问题

约束条件

对偶问题:

对偶问题

KKT 条件:

KKT 条件

预测模型:

预测模型

6.6 核方法

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

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

第7章 贝叶斯分类器

7.1 贝叶斯决策论

pass

7.2 极大似然估计

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

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

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

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

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

7.3 朴素贝叶斯分类器

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

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

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

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

注意点:

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

7.4 半朴素贝叶斯分类器

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

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

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

属性依赖图

如上图所示:

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

7.5 贝叶斯网

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

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

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

7.6 EM 算法

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

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

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

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

第8章 集成学习

8.1 个体与集成

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

多个不同的学习组件

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

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

8.2 Boosting

Boosting 算法族的逻辑:

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

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

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

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

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

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

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

代表算法:AdaBoost、GBDT、XGBoost

8.3 Bagging 与 随机森林

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

8.3.1 Bagging

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

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

8.3.2 随机森林

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

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

8.3.3 区别与联系(补)

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

提升了模型性能

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

8.4 区别与联系(补)

区别:

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

联系:

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

8.5 结合策略

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

8.5.1 平均法

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

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

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

8.5.2 投票法

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

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

8.5.3 学习法

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

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

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

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

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

8.6 多样性

8.6.1 误差-分歧分解

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

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

8.6.2 多样性度量

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

预测结果列联表

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

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

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

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

卡帕-统计量

8.6.3 多样性增强

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

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

第9章 聚类

9.1 聚类任务

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

9.2 性能度量

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

9.2.1 外部指标

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

外部指标变量

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

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

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

9.2.2 内部指标

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

内部指标变量

常见的外部指标如下:

  • DB 指数
  • Dunn 指数

9.3 距离计算

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

无序属性:VDM 距离

9.4 原型聚类

9.4.1 k 均值算法

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

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

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

k 均值算法伪代码

9.4.2 学习向量量化算法

9.4.3 高斯混合聚类算法

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

9.5 密度聚类

DBSCAN 算法

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

9.6 层次聚类

AGNES 算法

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

第10章 降维与度量学习

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

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

10.1 k 近邻学习

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

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

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

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

10.2 多维缩放降维

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

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

内积计算:

内积计算

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

新属性值计算

10.3 主成分分析降维

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

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

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

PCA 算法流程

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

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

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

步骤 1: 样本中心化

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

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

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

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

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

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

步骤 2: 计算协方差矩阵

样本的协方差矩阵为:

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

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

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

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

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

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

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

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

10.4 核化线性降维

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

10.5 流形学习降维

假定数据满足流形结构。

10.5.1 等度量映射

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

10.5.2 局部线性嵌入

pass

10.6 度量学习

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

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

马氏距离

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

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

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

10.6.1 近邻成分分析

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

10.6.2 LMNN

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

paper: Distance Metric Learning for Large Margin Nearest Neighbor Classification

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

第13章 半监督学习

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

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

三种半监督学习

13.1 未标记样本

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

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

13.2 生成式方法

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

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

  • 朴素贝叶斯
  • 高斯混合聚类
  • 马尔科夫模型

判别式方法:核心思想就是用条件分布 p(y  x)p(y\ |\ x) 进行建模,不对特征分布 p(x)p(x) 进行建模,完全不管数据是怎么来(生成)的。即直接学一个模型 p(y  x)p(y\ |\ x) 来对后续的输入进行预测。不需要对数据分布进行过多的假设。典型的例子如:

  • 线性回归
  • 逻辑回归
  • 决策树
  • 神经网络
  • 支持向量机
  • 条件随机场

自监督训练(补)

根本思想就是利用有标记数据进行模型训练,然后对未标记数据进行预测,选择置信度较高的一些样本加入训练集重新训练模型,不断迭代进行直到最终训练出来一个利用大量未标记数据训练出来的模型。

如何定义置信度高?我们利用信息熵的概念,即对于每一个测试样本都有一个预测向量,信息熵越大表明模型对其的预测结果越模糊,因此置信度高正比于信息熵小,将信息熵较小的测试样本打上「伪标记」加入训练集。

13.3 半监督 SVM

以经典 S3VM 中的经典算法 TSVM 为例。给出优化函数、算法图例、算法伪代码:

优化函数

算法图例

二分类 - 伪代码

13.4 图半监督学习

同样解决分类问题,以「迭代式标记传播算法」为例。

二分类,可以直接求出闭式解。

算法逻辑。每一个样本对应图中的一个结点,两个结点会连上一个边,边权正比于两结点样本的相似性。最终根据图中已知的某些结点进行传播标记即可。与基于密度的聚类算法类似,区别在于此处不同的簇 cluster 可能会对应同一个类别 class。

如何进行连边?不会计算每一个样本的所有近邻,一般采用局部近邻选择连边的点,可以 k 近邻,也可以范围近邻。

优化函数。定义图矩阵的能量损失函数为图中每一个结点与所有结点的能量损失和,目标就是最小化能量损失和:

优化函数

多分类,无法直接求出闭式解,只能进行迭代式计算。

新增变量。我们定义标记矩阵 F,其形状为 (l+u)×d(l+u) \times d,学习该矩阵对应的值,最终每一个未标记样本 xix_i 就是 argmaxFi\arg \max F_i

多分类 - 伪代码

13.5 基于分歧的方法

多学习器协同训练。

第14章 概率图模型

为了分析变量之间的关系,我们建立概率图模型。按照图中边的性质可将概率图模型分为两类:

  • 有向图模型,也称贝叶斯网
  • 无向图模型,也称马尔可夫网

14.1 隐马尔可夫模型

隐马尔可夫模型 (Hidden Markov Model, 简称 HMM)\text{(Hidden Markov Model, 简称 HMM)} 是结构最简单的动态贝叶斯网。是为了研究变量之间的关系而存在的,因此是生成式方法。

需要解决三个问题:

  1. 如何评估建立的网络模型和实际观测数据的匹配程度?
  2. 如果上面第一个问题中匹配程度不好,如何调整模型参数来提升模型和实际观测数据的匹配程度呢?
  3. 如何根据实际的观测数据利用网络推断出有价值的隐藏状态?

14.2 马尔科夫随机场

马尔科夫随机场 (Markov Random Field, 简称 MRF)\text{(Markov Random Field, 简称 MRF)} 是典型的马尔科夫网。同样是为了研究变量之间的关系而存在的,因此也是生成式方法。

联合概率计算逻辑按照势函数展开。其中团可以理解为完全子图;极大团就是结点最多的完全子图,即在当前的完全子图中继续添加子图之外的点就无法构成新的完全子图;势函数就是一个关于团的函数。

14.3 条件随机场

判别式方法。

14.4 学习和推断

精确推断。

14.5 近似推断

近似推断。降低计算时间开销。

  • 采样法:蒙特卡洛采样法
  • 变分推断:考虑近似分布

考试大纲

  • 单选 10 * 3':单选直接一个单刀咆哮。注意审题、注意一些绝对性的语句;
  • 简答 4 * 5':想拿满分就需要写出理论对应的具体公式;
  • 计算 3 * 10':老老实实写每一个计算过程,不要跳步骤;
  • 论述 2 * 10':没考过,大约是简答题加强版。注意逻辑一定要清晰。
]]> + 最优化方法

前言

学科地位:

主讲教师学分配额学科类别
王启春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。。。。你我皆是局内人,祝好。

]]> @@ -1073,11 +1073,11 @@ - DigitalLogicCircuit - - /GPA/3rd-term/DigitalLogicCircuit/ + DataStructure + + /GPA/3rd-term/DataStructure/ -

联系方式:

lijuncst@njnu.edu.cn

13770610040

数字逻辑电路

一、数字逻辑概论

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.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 HDLVerilog\ HDL 基础

为了从软件代码的角度描述电路,从下面三个方面介绍如何用相关的方式进行描述

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.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.1 组合逻辑电路的分析

4.1.1 定义

只取决于实时输入,从而给出相应的输出,与之前的运行结果无关。没有反馈没有记忆单元

4.1.2 分析步骤与方法

  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 组合逻辑电路中的竞争与冒险

4.3.1 产生的原因

门级元件的延时效应

4.3.2 消去的方法

  1. 消除互补变量

  2. 增加乘积项,避免互补项相加

  3. 输出端并联电容器

    image-20231117115217185

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}

4.5 用 verilog 描述组合逻辑电路

五、锁存器和触发器

本章介绍时序逻辑电路的存储单元,分别为锁存器和触发器。其中锁存器对电平敏感,触发器对边沿敏感

5.1 基本双稳态电路

image-20231222110345818

5.2 SRSR 锁存器

门级元件组成电路图功能分析
或非门实现image-20231222100822028高电平有效。全0不变,谁1谁有效,都1不确定状态
与非门实现image-20231222100904959低电平有效。全1不变,谁0谁有效,都0不确定状态
应用电路图功能分析
开关电路image-20231222101855246image-20231222102251343无论开关如何震动,输出始终正常
门控 SR 锁存器image-20231222103758704就是加了一个使能端 E,如果 E 为 1,则就是一个基本的 SR 锁存器,如果 E 为 0,则保持

5.3 DD 锁存器

电路名称逻辑电路图功能分析
传输门控制的D锁存器image-20231222105139215E=0, Q=不变;E=1, Q=D
逻辑门控制的D锁存器image-20240117191849592E=0, Q=不变;E=1, Q=D

5.4 触发器

5.4.1 主从 D 触发器的电路结构和工作原理

image-20231222112319354

5.4.2 典型的主从 D 触发器集成电路

image-20231222113334822
image-20231222113438371 image-20231222113452256
### 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.1 同步时序逻辑电路的分析

本部分只需要掌握同步时序逻辑电路的分析即可,具体直接从例题出发。三道同步时序逻辑电路分析的例题见教材 P282 ~ P286,分别为

  • 例一:可控二进制计数器
  • 例二:可控双向二进制计数器
  • 例三:脉冲分配器

下面介绍同步时序逻辑电路分析的五个步骤。在分析之前我们要知道我们的最终目标是什么,可以知道,我们分析电路的最终目标是想要量化的确定电路的物理实现的功能,至于如何设计,此处不予讨论。现在给定了一个同步时序逻辑电路的逻辑电路图,接下来我们应该:

  1. 了解电路组成:同步 or 异步?穆尔型输出(与输入无关) or 米利型输出(与输入有关) or 都有?由什么触发器组成的?触发器类型是上升沿出发 or 下降沿触发?

  2. 列出三个方程:

    • 输出方程:电路的最终输出

    • 激励方程:触发器的输入

    • 状态方程:触发器的输出(将触发器的输入也就是激励方程代入触发器的特性方程即可)

  3. 写出转换表(分析功能用)

  4. 写出状态图(分析功能用)

  5. 写出时序图(分析功能用)默认状态的初值设置为 0

6.2 典型的时序逻辑电路 - 计数器

主要讲 N 位二进制计数器中,利用集成电路板 74LVC16174LVC161 实现的 4 位同步二进制递增计数器。进而引出利用该 4 位计数器实现模 N 计数器的分析与设计思路。同时补充 74LVC16274LVC162 实现的 4 位同步十进制递增计时器,进而引出相关的模 N 设计思路。下面分析 74LVC161 4 位同步二进制递增计数器集成板

image-20240111233651905
图1 74LVC161集成板
image-20240111234456513
图2 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的状态,此时对高低片进行同步清零即可

6.3 用 verilog 描述同步时序逻辑电路

]]>
+

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)

]]>
@@ -1094,11 +1094,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

]]> @@ -1115,11 +1115,11 @@ - DataStructureClassDesign - - /GPA/3rd-term/DataStructureClassDesign/ + DigitalLogicCircuit + + /GPA/3rd-term/DigitalLogicCircuit/ - 数据结构课程设计

效果演示

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

]]> +

联系方式:

lijuncst@njnu.edu.cn

13770610040

数字逻辑电路

一、数字逻辑概论

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.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 HDLVerilog\ HDL 基础

为了从软件代码的角度描述电路,从下面三个方面介绍如何用相关的方式进行描述

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.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.1 组合逻辑电路的分析

4.1.1 定义

只取决于实时输入,从而给出相应的输出,与之前的运行结果无关。没有反馈没有记忆单元

4.1.2 分析步骤与方法

  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 组合逻辑电路中的竞争与冒险

4.3.1 产生的原因

门级元件的延时效应

4.3.2 消去的方法

  1. 消除互补变量

  2. 增加乘积项,避免互补项相加

  3. 输出端并联电容器

    image-20231117115217185

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}

4.5 用 verilog 描述组合逻辑电路

五、锁存器和触发器

本章介绍时序逻辑电路的存储单元,分别为锁存器和触发器。其中锁存器对电平敏感,触发器对边沿敏感

5.1 基本双稳态电路

image-20231222110345818

5.2 SRSR 锁存器

门级元件组成电路图功能分析
或非门实现image-20231222100822028高电平有效。全0不变,谁1谁有效,都1不确定状态
与非门实现image-20231222100904959低电平有效。全1不变,谁0谁有效,都0不确定状态
应用电路图功能分析
开关电路image-20231222101855246image-20231222102251343无论开关如何震动,输出始终正常
门控 SR 锁存器image-20231222103758704就是加了一个使能端 E,如果 E 为 1,则就是一个基本的 SR 锁存器,如果 E 为 0,则保持

5.3 DD 锁存器

电路名称逻辑电路图功能分析
传输门控制的D锁存器image-20231222105139215E=0, Q=不变;E=1, Q=D
逻辑门控制的D锁存器image-20240117191849592E=0, Q=不变;E=1, Q=D

5.4 触发器

5.4.1 主从 D 触发器的电路结构和工作原理

image-20231222112319354

5.4.2 典型的主从 D 触发器集成电路

image-20231222113334822
image-20231222113438371 image-20231222113452256
### 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.1 同步时序逻辑电路的分析

本部分只需要掌握同步时序逻辑电路的分析即可,具体直接从例题出发。三道同步时序逻辑电路分析的例题见教材 P282 ~ P286,分别为

  • 例一:可控二进制计数器
  • 例二:可控双向二进制计数器
  • 例三:脉冲分配器

下面介绍同步时序逻辑电路分析的五个步骤。在分析之前我们要知道我们的最终目标是什么,可以知道,我们分析电路的最终目标是想要量化的确定电路的物理实现的功能,至于如何设计,此处不予讨论。现在给定了一个同步时序逻辑电路的逻辑电路图,接下来我们应该:

  1. 了解电路组成:同步 or 异步?穆尔型输出(与输入无关) or 米利型输出(与输入有关) or 都有?由什么触发器组成的?触发器类型是上升沿出发 or 下降沿触发?

  2. 列出三个方程:

    • 输出方程:电路的最终输出

    • 激励方程:触发器的输入

    • 状态方程:触发器的输出(将触发器的输入也就是激励方程代入触发器的特性方程即可)

  3. 写出转换表(分析功能用)

  4. 写出状态图(分析功能用)

  5. 写出时序图(分析功能用)默认状态的初值设置为 0

6.2 典型的时序逻辑电路 - 计数器

主要讲 N 位二进制计数器中,利用集成电路板 74LVC16174LVC161 实现的 4 位同步二进制递增计数器。进而引出利用该 4 位计数器实现模 N 计数器的分析与设计思路。同时补充 74LVC16274LVC162 实现的 4 位同步十进制递增计时器,进而引出相关的模 N 设计思路。下面分析 74LVC161 4 位同步二进制递增计数器集成板

image-20240111233651905
图1 74LVC161集成板
image-20240111234456513
图2 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的状态,此时对高低片进行同步清零即可

6.3 用 verilog 描述同步时序逻辑电路

]]>
@@ -1422,11 +1422,11 @@ - games - - /Algorithm/games/ + geometry + + /Algorithm/geometry/ - 博弈论

思考如何必胜态和必败态是什么以及如何构造这样的局面。

【博弈/贪心/交互】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;
}
]]>
+ 计算几何

【二维/数学】Minimum Manhattan Distance

https://codeforces.com/gym/104639/problem/J

题意:给定两个圆的直径的两个点坐标,其中约束条件是两个圆一定是处在相离的两个角上。问如何在C2圆上或圆内找到一点p,使得点p到C1圆的所有点的曼哈顿距离的期望值最小

思路:

  • 看似需要积分,其实我们可以发现,对于点p到C1中某个点q1的曼哈顿距离,我们一定可以找到q1关于C1对称的点q2,那么点p到q1和q2的曼哈顿距离之和就是点p到C1的曼哈顿距离的两倍(证明就是中线定理)那么期望的最小值就是点p到C1的曼哈顿距离的最小值。目标转化后,我们开始思考如何计算此目标的最小值,思路如下图

    image-20240116175917260

注意点:

  • double的读取速度很慢,可以用 int or long long 读入,后续强制类型转换(显示 or 和浮点数计算)
  • 注意输出答案的精度控制 cout << fixed << setprecision(10) << res << "\n";
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void solve() {
double x1, y1, x2, y2;
long long a, b, c, d;

cin >> a >> b >> c >> d;
x1 = (a + c) / 2.0;
y1 = (b + d) / 2.0;

cin >> a >> b >> c >> d;
x2 = (a + c) / 2.0;
y2 = (b + d) / 2.0;

double r2 = sqrt((a - c) * (a - c) + (b - d) * (b - d)) / 2;

cout << fixed << setprecision(10) << abs(x1 - x2) + abs(y1 - y2) - sqrt(2) * r2 << "\n";
}

【枚举】三角形

https://www.acwing.com/problem/content/5383/

题意:给定两个直角三角形的两条直角边的长度 a, b,问能不能在坐标轴上找到三个整数点使得三点满足该直角三角形且三遍均不与坐标轴垂直

思路:首先确定两个直角边的顶点为原点 (0, 0),接着根据对称性直接在第一象限中按照边长枚举其中一个顶点 A,对于每一个枚举到的顶点 A,按照斜率枚举最后一个顶点 B,如果满足长度以及不平行于坐标轴的条件就是合法的点。如果全部枚举完都没有找到就是没有合法组合,直接输出 NO 即可。

时间复杂度:O(a2b)O(a^2b)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#include <bits/stdc++.h>
using namespace std;

#define int long long

int a, b;

void solve() {
cin >> a >> b;

for (int i = 0; i <= a; i++) {
for (int j = 0; j <= a; j++) {
if (i * i + j * j == a * a && i && j) {
int gcd = __gcd(i, j);
int p = -i / gcd, q = j / gcd;
int y = p, x = q;
while (x * x + y * y < b * b) {
x += q, y += p;
}
if (x * x + y * y == b * b && x != i && y != j) {
cout << "YES\n";
cout << 0 << ' ' << 0 << "\n";
cout << i << ' ' << j << "\n";
cout << x << ' ' << y << "\n";
return;
}
}
}
}

cout << "NO\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
//cin >> T;
while (T--) solve();
return 0;
}

【凸包】奶牛过马路

https://www.acwing.com/problem/content/5572/

题意:给定一个二维坐标系,现在有两个东西可以进行平移。

  • 一个是起始位于 (0,0)(0,0) 的奶牛,可以沿着 yy 轴以 [0,u][0,u] 的速度正向或负向移动
  • 一个是起始位于第一象限的凸包形状的车辆,可以向 xx 轴负半轴以恒定的速度 vv 移动

在给定凸包的 nn 个顶点的情况下,以及不允许奶牛被车辆撞到的情况下,奶牛前进到 (0,w)(0,w) 的最短时间

思路:由于是凸包,因此每一个顶点同时满足的性质,就可以代表整个凸包满足的性质。那么对于奶牛和凸包,就一共有三个局面:

  1. 奶牛速度足够快。可以使得对于任意一个顶点 (x,y)(x,y) 在到达 y 轴时,都小于此时奶牛的 y 值,用时间来表示就是 yuxv\frac{y}{u} \le \frac{x}{v},即 yuvxy \le \frac{u}{v}x,表示凸包上所有的顶点都在直线 y=uvy=\frac{u}{v} 的下方
  2. 凸包速度足够快。可以使得对于任意一个顶点 (x,y)(x,y) 在到达 y 轴时,都大于此时奶牛的 y 值,用时间来表示就是 yuxv\frac{y}{u} \ge \frac{x}{v},即 yuvxy \ge \frac{u}{v}x,表示凸包上所有的顶点都在直线 y=uvy=\frac{u}{v} 的上方
  3. 上述两种都不满足,即直线 y=uvy=\frac{u}{v} 与凸包相交。此时奶牛就不能一直全速(u)前进到终点了,需要进行一定的减速 or 返回操作。有一个显然的结论就是,对于减速 or 返回的操作,都可以等价于后退一段距离后再全速(u)前进,因此对于需要减速 or 返回的操作,我们只需要计算出当前状态下应该后退的距离即可。再简洁的,我们就是需要平移直线 y=uvy=\frac{u}{v},显然只能向下平移而非向上平移,因此其实就是计算出直线 y=uv+by=\frac{u}{v}+b 的截距 bb 使得最终的直线 y=uv+by=\frac{u}{v}+b 满足第二种条件即可。那么如何计算这个 b 呢?很显然我们可以遍历每一个顶点取满足所有顶点都在直线 y=uv+by=\frac{u}{v}+b​ 上方的最大截距 b 即可。

注意点:

  • 浮点数比较注意精度误差,尽可能将除法转化为乘法
  • 在比较相等时可以引入一个无穷小量 ϵ=107\epsilon=10^{-7}
  • 注意答案输出精度要求,10610^{-6} 就需要我们出至少 77 位小数

时间复杂度:O(n)O(n)

C++

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include <iostream>
#include <cstring>
#include <algorithm>
#include <iomanip>
using namespace std;
using ll = long long;

int main() {
double n, w, v, u;
cin >> n >> w >> v >> u;

bool up = false, down = false;
double b = 0;

while (n--) {
double x, y;
cin >> x >> y;

// 除法无法通过
// if (y / x < u / v) down = true;
// else if (y / x > u / v) up = true;

// 转化为乘法以后才能通过
if (y < u / v * x) down = true;
else if (y > u / v * x) up = true;

b = min(b, y - u / v * x);
}

if (up && down) cout << setprecision(7) << (w - b) / u;
else cout << setprecision(7) << w / u;

return 0;
}

Py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
read = lambda: map(int, input().split())

n, w, v, u = read()
k = u / v
b = 0
up = False
down = False

for _ in range(n):
x, y = read()
if (y < k * x): down = True
if (y > k * x): up = True
b = min(b, y - k * x)

if (up and down): print((w - b) / u)
else: print(w / u)
]]>
@@ -1441,11 +1441,11 @@ - geometry - - /Algorithm/geometry/ + games + + /Algorithm/games/ - 计算几何

【二维/数学】Minimum Manhattan Distance

https://codeforces.com/gym/104639/problem/J

题意:给定两个圆的直径的两个点坐标,其中约束条件是两个圆一定是处在相离的两个角上。问如何在C2圆上或圆内找到一点p,使得点p到C1圆的所有点的曼哈顿距离的期望值最小

思路:

  • 看似需要积分,其实我们可以发现,对于点p到C1中某个点q1的曼哈顿距离,我们一定可以找到q1关于C1对称的点q2,那么点p到q1和q2的曼哈顿距离之和就是点p到C1的曼哈顿距离的两倍(证明就是中线定理)那么期望的最小值就是点p到C1的曼哈顿距离的最小值。目标转化后,我们开始思考如何计算此目标的最小值,思路如下图

    image-20240116175917260

注意点:

  • double的读取速度很慢,可以用 int or long long 读入,后续强制类型转换(显示 or 和浮点数计算)
  • 注意输出答案的精度控制 cout << fixed << setprecision(10) << res << "\n";
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void solve() {
double x1, y1, x2, y2;
long long a, b, c, d;

cin >> a >> b >> c >> d;
x1 = (a + c) / 2.0;
y1 = (b + d) / 2.0;

cin >> a >> b >> c >> d;
x2 = (a + c) / 2.0;
y2 = (b + d) / 2.0;

double r2 = sqrt((a - c) * (a - c) + (b - d) * (b - d)) / 2;

cout << fixed << setprecision(10) << abs(x1 - x2) + abs(y1 - y2) - sqrt(2) * r2 << "\n";
}

【枚举】三角形

https://www.acwing.com/problem/content/5383/

题意:给定两个直角三角形的两条直角边的长度 a, b,问能不能在坐标轴上找到三个整数点使得三点满足该直角三角形且三遍均不与坐标轴垂直

思路:首先确定两个直角边的顶点为原点 (0, 0),接着根据对称性直接在第一象限中按照边长枚举其中一个顶点 A,对于每一个枚举到的顶点 A,按照斜率枚举最后一个顶点 B,如果满足长度以及不平行于坐标轴的条件就是合法的点。如果全部枚举完都没有找到就是没有合法组合,直接输出 NO 即可。

时间复杂度:O(a2b)O(a^2b)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#include <bits/stdc++.h>
using namespace std;

#define int long long

int a, b;

void solve() {
cin >> a >> b;

for (int i = 0; i <= a; i++) {
for (int j = 0; j <= a; j++) {
if (i * i + j * j == a * a && i && j) {
int gcd = __gcd(i, j);
int p = -i / gcd, q = j / gcd;
int y = p, x = q;
while (x * x + y * y < b * b) {
x += q, y += p;
}
if (x * x + y * y == b * b && x != i && y != j) {
cout << "YES\n";
cout << 0 << ' ' << 0 << "\n";
cout << i << ' ' << j << "\n";
cout << x << ' ' << y << "\n";
return;
}
}
}
}

cout << "NO\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
//cin >> T;
while (T--) solve();
return 0;
}

【凸包】奶牛过马路

https://www.acwing.com/problem/content/5572/

题意:给定一个二维坐标系,现在有两个东西可以进行平移。

  • 一个是起始位于 (0,0)(0,0) 的奶牛,可以沿着 yy 轴以 [0,u][0,u] 的速度正向或负向移动
  • 一个是起始位于第一象限的凸包形状的车辆,可以向 xx 轴负半轴以恒定的速度 vv 移动

在给定凸包的 nn 个顶点的情况下,以及不允许奶牛被车辆撞到的情况下,奶牛前进到 (0,w)(0,w) 的最短时间

思路:由于是凸包,因此每一个顶点同时满足的性质,就可以代表整个凸包满足的性质。那么对于奶牛和凸包,就一共有三个局面:

  1. 奶牛速度足够快。可以使得对于任意一个顶点 (x,y)(x,y) 在到达 y 轴时,都小于此时奶牛的 y 值,用时间来表示就是 yuxv\frac{y}{u} \le \frac{x}{v},即 yuvxy \le \frac{u}{v}x,表示凸包上所有的顶点都在直线 y=uvy=\frac{u}{v} 的下方
  2. 凸包速度足够快。可以使得对于任意一个顶点 (x,y)(x,y) 在到达 y 轴时,都大于此时奶牛的 y 值,用时间来表示就是 yuxv\frac{y}{u} \ge \frac{x}{v},即 yuvxy \ge \frac{u}{v}x,表示凸包上所有的顶点都在直线 y=uvy=\frac{u}{v} 的上方
  3. 上述两种都不满足,即直线 y=uvy=\frac{u}{v} 与凸包相交。此时奶牛就不能一直全速(u)前进到终点了,需要进行一定的减速 or 返回操作。有一个显然的结论就是,对于减速 or 返回的操作,都可以等价于后退一段距离后再全速(u)前进,因此对于需要减速 or 返回的操作,我们只需要计算出当前状态下应该后退的距离即可。再简洁的,我们就是需要平移直线 y=uvy=\frac{u}{v},显然只能向下平移而非向上平移,因此其实就是计算出直线 y=uv+by=\frac{u}{v}+b 的截距 bb 使得最终的直线 y=uv+by=\frac{u}{v}+b 满足第二种条件即可。那么如何计算这个 b 呢?很显然我们可以遍历每一个顶点取满足所有顶点都在直线 y=uv+by=\frac{u}{v}+b​ 上方的最大截距 b 即可。

注意点:

  • 浮点数比较注意精度误差,尽可能将除法转化为乘法
  • 在比较相等时可以引入一个无穷小量 ϵ=107\epsilon=10^{-7}
  • 注意答案输出精度要求,10610^{-6} 就需要我们出至少 77 位小数

时间复杂度:O(n)O(n)

C++

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include <iostream>
#include <cstring>
#include <algorithm>
#include <iomanip>
using namespace std;
using ll = long long;

int main() {
double n, w, v, u;
cin >> n >> w >> v >> u;

bool up = false, down = false;
double b = 0;

while (n--) {
double x, y;
cin >> x >> y;

// 除法无法通过
// if (y / x < u / v) down = true;
// else if (y / x > u / v) up = true;

// 转化为乘法以后才能通过
if (y < u / v * x) down = true;
else if (y > u / v * x) up = true;

b = min(b, y - u / v * x);
}

if (up && down) cout << setprecision(7) << (w - b) / u;
else cout << setprecision(7) << w / u;

return 0;
}

Py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
read = lambda: map(int, input().split())

n, w, v, u = read()
k = u / v
b = 0
up = False
down = False

for _ in range(n):
x, y = read()
if (y < k * x): down = True
if (y > k * x): up = True
b = min(b, y - k * x)

if (up and down): print((w - b) / u)
else: print(w / u)
]]>
+ 博弈论

思考如何必胜态和必败态是什么以及如何构造这样的局面。

【博弈/贪心/交互】Salyg1n and the MEX Game

https://codeforces.com/contest/1867/problem/C

标签:博弈、贪心、交互

题面:对于给定n个数的数列,先手可以放入一个数列中不存在的数(0-1e9),后手可以从数列中拿掉一个数,但是这个数必须严格小于刚才先手放入的数。终止条件:后手没法拿数或者操作次数达到了 2n+1 次。问:当你是先手时,如何放数可以使得最终数列的 MEX 值最大。

思路:先手每次放入的数一定是当前数列的 MEX 值,此后不管后手拿掉什么数,先手都将刚刚被拿掉的数放进去即可。那么最多操作次数就刚好是 2n+1次,因为加入当前数列就是一个从 0 开始的连续整数数列,那么先手放入的数就是最大数 +1,即 n,那么假如后手从 n-1 开始拿,后手最多拿 n 次,先手再放 n 次,那么就是 2n+1 次。

时间复杂度:O(n)O(n)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#include <iostream>
#include <vector>

using namespace std;

int main()
{
int T; cin >> T;

while (T--)
{
int n; cin >> n;
vector<int> a(n);

for (int i = 0; i < n; i ++)
cin >> a[i];

int mex = n;
for (int i = 0; i < n; i ++)
if (a[i] != i)
{
mex = i;
break;
}

cout << mex << endl;

int remove;
cin >> remove;

while (remove != -1)
{
cout << remove << endl;
cin >> remove;
}
}

return 0;
}
]]>
@@ -1460,11 +1460,11 @@ - graphs - - /Algorithm/graphs/ + hashing + + /Algorithm/hashing/ - 图论

【拓扑】有向图的拓扑序列

https://www.acwing.com/problem/content/850/

题意:输出一个图的拓扑序,不存在则输出-1

思路:

  • 首先我们要知道拓扑图的概念,感官上就是一张图可以从一个方向拓展到全图,用数学语言就是:若一个由图中所有点构成的序列 A 满足:对于图中的每条边 (x,y),x 在 A 中都出现在 y 之前,则称 A 是该图的一个拓扑序列
  • 接着我们就想要寻找这样的序列 A 了,可以发现对于每一个可扩展的点,入度一定为0,那么我们就从这些点开始宽搜,将搜到的点的入度-1,即删除这条边,直到最后。如果全图的点的入度都变为了0,则此图可拓扑

时间复杂度:O(n+m)O(n+m)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
#include <bits/stdc++.h>
using namespace std;

const int N = 100010;

int n, m;
vector<int> G[N];

void solve() {
// 建图
cin >> n >> m;
vector<int> d(n + 1, 0);
for (int i = 1; i <= m; i++) {
int a, b;
cin >> a >> b;
d[b]++;
G[a].push_back(b);
}

// 预处理宽搜起始点集
queue<int> q;
for (int i = 1; i <= n; i++)
if (!d[i])
q.push(i);

// 宽搜处理
vector<int> res;
while (q.size()) {
auto h = q.front();
q.pop();
res.push_back(h);

for (auto& ch: G[h]) {
d[ch]--;
if (!d[ch]) q.push(ch);
}
}

// 输出合法拓扑序
if (res.size() == n) {
for (auto& x: res) {
cout << x << " ";
}
} else {
cout << -1 << "\n";
}
}

int main() {
solve();
return 0;
}

【基环树/拓扑】Mad City🔥

https://codeforces.com/contest/1873/problem/H

tag:基环树、拓扑排序

题意:给定一个基环树,现在图上有两个点,分别叫做A,B。现在B想要逃脱A的抓捕,问对于给定的局面,B能否永远逃离A的抓捕

思路:思路很简单,我们只需要分B所在位置的两种情况讨论即可

  1. B不在环上:此时我们记距离B最近的环上的那个点叫 tagtag,我们需要比较的是A到tag点的距离 dAd_A 和B到tag的距离 dBd_B,如果 dB<dAd_B < d_A,则一定可以逃脱,否则一定不可以逃脱
  2. B在环上:此时我们只需要判断当前的A点是否与B的位置重合即可,如果重合那就无法逃脱,反之B一定可以逃脱。

代码实现:

  1. 对于第一种情况,我们需要找到tag点以及计算A和B到tag点的距离,

时间复杂度:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
#include <bits/stdc++.h>
using namespace std;

const int N = 200010;

int n, a, b;
vector<int> G[N];
int rd[N], tag, d[N];
bool del[N], vis[N];

void init() {
for (int i = 1; i <= n; i++) {
G[i].clear(); // 存无向图
rd[i] = 0; // 统计每一个结点的入度
del[i] = false; // 拓扑删点删边时使用
d[i] = 0; // 图上所有点到 tag 点的距离
vis[i] = false; // bfs计算距离时使用
}
}

void topu(int now) {
if (rd[now] == 1) {
rd[now]--;
del[now] = true;
for (auto& ch: G[now]) {
if (del[ch]) continue;
rd[ch]--;
if (now == tag) {
tag = ch;
}
topu(ch);
}
}
}

void bfs() {
queue<int> q;
q.push(tag);
d[tag] = 0;

while (q.size()) {
auto now = q.front();
vis[now] = true;
q.pop();

for (auto& ch: G[now]) {
if (!vis[ch]) {
d[ch] = d[now] + 1;
q.push(ch);
vis[ch] = true;
}
}
}
}

void solve() {
// 初始化
cin >> n >> a >> b;
init();

// 建图
for (int i = 1; i <= n; i++) {
int u, v;
cin >> u >> v;
G[u].push_back(v), rd[v]++;
G[v].push_back(u), rd[u]++;
}

// 拓扑删边 & 缩b点
tag = b;
for (int i = 1; i <= n; i++) {
topu(i);
}

// 判断结果 & 计算距离
if (rd[b] == 2 && a != b) {
// b点在环上
cout << "Yes\n";
} else {
// b不在环上
bfs();
cout << (d[a] > d[b] ? "Yes\n" : "No\n");
}
}

int main() {
ios::sync_with_stdio(false);
cin.tie(0);
int T = 1;
cin >> T;
while (T--) solve();
return 0;
}

【二分图】染色法判定二分图

https://www.acwing.com/problem/content/862/

题意:给定一个无向图,可能有重边和自环。问是否可以构成二分图。

二分图的定义:一个图可以被分成两个点集,每个点集内部没有边相连(可以不是连通图)

思路:利用染色法,遍历每一个连通分量,选择连通分量中的任意一点进行染色扩展

  • 如果扩展到的点没有染过色,则染成与当前点相对的颜色
  • 如果扩展到的点已经被染过色了且染的颜色和当前点的颜色相同,则无法构成二分图(奇数环)

时间复杂度:O(n+e)O(n+e)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
const int N = 100010;

int n, m;
vector<int> G[N], col(N);

bool bfs(int u) {
queue<int> q;
q.push(u);
col[u] = 1;

while (q.size()) {
int now = q.front();
q.pop();
for (auto& ch: G[now]) {
if (!col[ch]) {
col[ch] = -col[now];
q.push(ch);
}
else if (col[ch] == col[now]) {
return false;
}
}
}

return true;
}

void solve() {
cin >> n >> m;
while (m--) {
int u, v;
cin >> u >> v;
G[u].push_back(v);
G[v].push_back(u);
}

// 遍历每一个连通分量
for (int i = 1; i <= n; i++) {
if (!col[i]) {
bool ok = bfs(i);
if (!ok) {
cout << "No\n";
return;
}
}
}

cout << "Yes\n";
}

【最小生成树】Kruskal算法求最小生成树

https://www.acwing.com/problem/content/861/

题意:给定一个无向图,可能含有重边和自环。试判断能否求解其中的最小生成树,如果可以给出最小生成树的权值

思路:根据数据量,可以发现顶点数很大,不适用 PrimPrim 算法,只能用 KruskalKruskal 算法,下面简单介绍一下该算法的流程

  • 自环首先排除 - 显然这条边连接的“两个”顶点是不可能选进 MSTMST
  • 首先将每一个结点看成一个连通分量
  • 接着按照权值将所有的边升序排序后,依次选择
    • 如果选出的这条边的两个顶点不在一个连通分量中,则选择这条边并将两个顶点所在的连通分量合并
    • 如果选出的这条边的两个顶点在同一个连通分量中,则不能选择这条边(否则会使得构造的树形成环)
  • 最后统计选择的边的数量 numnum 进行判断即可
    • num=n1num=n-1,则可以生成最小生成树
    • num<n1num<n-1,则无法生成最小生成树
  • 时间复杂度:O(eloge)O(e\log e)​ - 因为最大的时间开销在对所有的边的权值进行排序上

C++

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

const int N = 100010;

struct edge {
int a, b;
int w;
};

int n, m;
vector<edge> edges;
vector<int> p(N);

int Find(int now) {
if (p[now] != now) {
p[now] = Find(p[now]);
}
return p[now];
}

void solve() {
cin >> n >> m;
for (int i = 1; i <= m; i++) {
int a, b, w;
cin >> a >> b >> w;
if (a == b) {
continue;
}
edges.push_back({a, b, w});
}

// 按照边权升序排序
sort(edges.begin(), edges.end(), [&](edge& x, edge& y) {
return x.w < y.w;
});

// 选边
for (int i = 1; i <= n; i++) {
p[i] = i;
}

int res = 0, num = 0;

for (auto& e: edges) {
int pa = Find(e.a), pb = Find(e.b);
if (pa != pb) {
num++;
p[pa] = pb;
res += e.w;
}

if (num == n - 1) {
break;
}
}

// 特判:选出来的边数无法构成一棵树
if (num < n - 1) {
cout << "impossible\n";
return;
}

cout << res << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

Python

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
def Find(x: int, p: list) -> int:
if p[x] != x: p[x] = Find(p[x], p)
return p[x]

def kruskal(n: int, m: int, edges: list) -> int:
# 按边权对边进行降序排序
edges.sort(key=lambda edge: edge[-1])

# dsu 初始化
p = [None] + [i for i in range(1, n + 1)]

# 选边
cnt = sum = 0
for edge in edges:
if cnt == n - 1: break

pa, pb = Find(edge[0], p), Find(edge[1], p)
if pa != pb:
p[pa] = pb
cnt += 1
sum += edge[2]

return sum if cnt == n - 1 else 0


if __name__ == "__main__":
n, m = map(int, input().split())

edges = []
for i in range(m):
edge = tuple(map(int, input().split()))
edges.append(edge)

res = kruskal(n, m, edges)

if res: print(res)
else: print("impossible")

JavaScript

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
const readline = require('readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});

let n = null;
let m = null;
const edges = [];

rl.on('line', line => {
const [a, b, c] = line.split(' ').map(i => Number(i));
if (n === null) {
n = a;
m = b;
} else {
edges.push([a, b, c]);
}
});

rl.on('close', () => {
const res = kurskal(n, m, edges);
console.log(res === Infinity ? 'impossible' : res);
});

function Find(x, p) {
if (p[x] != x) p[x] = Find(p[x], p);
return p[x];
}

function kurskal(n, m, edges) {
// 对边进行升序排序
edges.sort((a, b) => a[2] - b[2]);

// 初始化 dsu
p = [];
for (let i = 1; i <= n; i++) p[i] = i;

// 选边
let cnt = 0, sum = 0;
for (let [a, b, w] of edges) {
if (cnt == n - 1) {
break;
}

let pa = Find(a, p), pb = Find(b, p);
if (pa !== pb) {
cnt++;
p[pa] = pb;
sum += w;
}
}

if (cnt === n - 1) return sum;
else return Infinity;
}

【最小生成树】Prim算法求最小生成树

https://www.acwing.com/problem/content/860/

题意:给定一个稠密无向图,有重边和自环。求出最小生成树

思路:根据题目的数据量,可以使用邻接矩阵存储的方法配合 PrimPrim 算法求解最小生成树,下面给出该算法的流程

  • 首先明确一下变量的定义:
    • g[i][j] 为无向图的邻接矩阵存储结构
    • MST[i] 表示 ii 号点是否加入了 MSTMST 集合
    • d[i] 表示 i 号点到 MSTMST 集合的最短边长度
  • 自环不存储,重边只保留最短的一条
  • 任选一个点到集合 MSTMST 中,并且更新 dd 数组
  • 选择剩余的 n1n-1 个点,每次选择有以下流程
    • 找到最短边,记录最短边长度 ee 和相应的在 UMSTU-MST 集合中对应的顶点序号 vv
    • vv 号点加入 MSTMST 集合,同时根据此时选出的最短边的长度来判断是否存在最小生成树
    • 根据 vv 号点,更新 dd 数组,即更新在集合 UMSTU-MST 中的点到 MSTMST 集合中的点的交叉边的最短长度
  • 时间复杂度:O(n2)O(n^2)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

const int N = 510;

int n, m;
vector<vector<int>> g(N, vector<int>(N, INT_MAX));
vector<int> d(N, INT_MAX); // d[i]表示i号点到MST集合中的最短边长度
bool MST[N];
int res;

void prim() {
// 选任意一个点到MST中并更新d数组
MST[1] = true;
for (int i = 1; i <= n; i++)
if (!MST[i])
d[i] = min(d[i], g[i][1]);

// 选剩下的n-1个点到MST中
for (int i = 2; i <= n; i++) {
// 1. 找到最短边
int e = INT_MAX, v = -1; // e: 最短边长度,v: 最短边不在MST集合中的顶点
for (int j = 1; j <= n; j++)
if (!MST[j] && d[j] < e)
e = d[j], v = j;

// 2. 加入MST集合
MST[v] = true;
if (e == INT_MAX) {
// 特判无法构造MST的情况
cout << "impossible\n";
return;
} else {
res += e;
}

// 3. 更新交叉边 - 迭代(覆盖更新)
for (int j = 1; j <= n; j++)
if (!MST[j])
d[j] = min(d[j], g[j][v]);
}

cout << res << "\n";
}

void solve() {
cin >> n >> m;
while (m--) {
int a, b, w;
cin >> a >> b >> w;

if (a == b) {
continue;
}

if (g[a][b] == INT_MAX) {
g[a][b] = w;
g[b][a] = w;
} else {
g[a][b] = min(g[a][b], w);
g[b][a] = min(g[b][a], w);
}
}

prim();
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【最短路】Dijkstra求最短路 🔥

朴素版 - https://www.acwing.com/problem/content/851/

堆优化 - https://www.acwing.com/problem/content/852/

题意:给定一个正边权的有向图,可能存在重边与自环,问 11 号点到 nn 号点的最短路径长度是多少,如果不可达就输出 1-1

思路一:朴素版。点数 1n5001\le n \le 500,边数 1m1051 \le m\le 10^5

  • 思路:根据数据量,我们采用邻接矩阵的方式存储「点少边多」的稠密图。我们定义 d[i] 数组表示起点到 i 号点的最短距离。先将起点放入 SPT (Shortest Path Tree) 集合,然后更新所有 V-SPT 中的点到 SPT 集合的最短路径长度。接着循环 n-1 次迭代更新剩余的 n-1 个点,每次迭代的过程中,首先选择距离起点最近的点 vex,然后将该点加入 SPT 集合,最后利用该点更新 V-SPT 集合中和该点有连边的点到起点的最短距离。最终的 d[end] 就是起点 start 到终点 end 的最短距离。

  • 总结:算法整体采用贪心与动态规划的思路。与 Prim\text{Prim} 算法仔细比对可知,其中的贪心过程几乎一致,即每次选择加入 SPT 集合的点均为当前局面 V-SPT 集合中距离起点最近的点。而动态规划的过程体现在,在求解出集合 V-SPT 中到集合 STP 最短距离的点 vex 之后,利用该点对「在 V-SPT 集合且和 vex 点有连边的点 i」更新 d[i] 的过程。更新前的状态都是在之前的子结构下的最优解。

  • 时间复杂度:O(n2)O(n^2)

思路二:堆优化。点数 1n1.5×1051\le n \le 1.5 \times 10^5,边数 1m1.5×1051 \le m \le 1.5 \times 10^5

  • 思路:根据数据量,我们采用邻接表的方式存储「点多边少」的稀疏图。如果采用上述朴素 Dijkstra 算法进行求解必然会因为点数过多而超时,因此我们利用数据结构「堆」进行时间开销上的优化。不难发现朴素 Dijkstra 算法在迭代过程中主要有三部分:

    1. 选择距离起点最近的点 vex。因为需要枚举所有的顶点,因此总的时间复杂度为 O(n2)O(n^2)
    2. 将该点加入 SPT 集合。因为只是简单的打个标记,因此总的时间复杂度为 O(n)O(n)
    3. 利用该点更新 V-SPT 集合中和该点相连的点到起点的最短距离。因为此时枚举的是该点所有的连边,而邻接表的图存储方式无法进行重边的删除,因此最坏情况下会枚举所有的边,时间复杂度为 O(m)O(m)
  • 时间复杂度:

朴素版C++:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
#include <bits/stdc++.h>

using ll = long long;
using namespace std;

int dijkstra_ori(std::vector<std::vector<int>>& g, int start, int end) {
int n = g.size() - 1;
std::vector<int> d(n + 1, INT_MAX >> 1);
std::vector<bool> SPT(n + 1, false);

// update start vex
d[start] = 0;
SPT[start] = true;
for (int i = 1; i <= n; i++) {
if (!SPT[i] && g[start][i] != INT_MAX >> 1) {
d[i] = std::min(d[i], d[start] + g[start][i]);
}
}

// update remain n-1 vex
for (int k = 0; k < n - 1; k++) {
int vex = -1;
for (int i = 1; i <= n; i++) {
if (!SPT[i] && (vex == -1 || d[i] < d[vex])) {
vex = i;
}
}
SPT[vex] = true;
for (int i = 1; i <= n; i++) {
if (!SPT[i] && g[vex][i] != INT_MAX >> 1) {
d[i] = std::min(d[i], d[vex] + g[vex][i]);
}
}
}

return d[end] == INT_MAX >> 1 ? -1 : d[end];
}

void solve() {
int n, m;
cin >> n >> m;

vector<vector<int>> g(n + 1, vector<int>(n + 1, INT_MAX >> 1));

while (m--) {
int u, v, w;
cin >> u >> v >> w;
g[u][v] = min(g[u][v], w);
}

cout << dijkstra_ori(g, 1, n) << "\n";
}

signed main() {
std::ios::sync_with_stdio(false);
std::cin.tie(nullptr);
int T = 1;
// std::cin >> T;
while (T--) solve();
return 0;
}

朴素版Python:

[]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import heapq
from collections import defaultdict
from typing import List, Tuple
import math
from itertools import combinations

II = lambda: int(input())
FI = lambda: float(input())
MII = lambda: tuple(map(int, input().split()))
LII = lambda: list(map(int, input().split()))


def dijkstra_ori(g: List[List[int]], start: int, end: int) -> int:
n = len(g) - 1
d = [10 ** 5] * (n + 1)
SPT = [False] * (n + 1)

d[start] = 0
SPT[start] = True
for i in range(1, n + 1):
if not SPT[i] and g[start][i] != 10 ** 5:
d[i] = min(d[i], d[start] + g[start][i])

for _ in range(n - 1):
vex = -1
for i in range(1, n + 1):
if not SPT[i] and (vex == -1 or d[i] < d[vex]):
vex = i
SPT[vex] = True
for i in range(1, n + 1):
if not SPT[i] and g[vex][i] != 10 ** 5:
d[i] = min(d[i], d[vex] + g[vex][i])

return -1 if d[end] == 10 ** 5 else d[end]


def solve() -> None:
n, m = MII()
g = [[10 ** 5] * (n + 1) for _ in range(n + 1)]
for _ in range(m):
u, v, w = MII()
g[u][v] = min(g[u][v], w)
print(dijkstra_ori(g, 1, n))


if __name__ == '__main__':
T = 1
# T = II()
while T: solve(); T -= 1

堆优化版C++:

1
2
3
4
5
6
```

堆优化版Python:

```python

【最短路】Floyd求最短路

https://www.acwing.com/problem/content/856/

题意:给定一个稠密有向图,可能存在重边与自环,给出多个询问,需要给出每一个询问的两个点之前的最短路径长度

思路:我们采用动态规划的思路。在此使用多阶段决策的方法,即每一个路径状态为选择 1k1\to k 个点的情况下的最短路径长度

  • 状态表示:f[k][i][j] 表示在前 kk 个顶点中进行选择(中转),ii 号点到 jj 号点的最短路径长度
  • 状态转移:对于第 kk 个顶点,我们可以选择中转,也可以不中转。
    • 对于不选择中转的情况:f[k][i][j] = f[k-1][i][j]
    • 对于可选择中转的情况:f[k][i][j] = f[k-1][i][k] + f[k-1][k][j]
    • 在其中取最小值即可,但是有一个注意点:对于第二种情况,选择是有一个约束的:即如果选择了 kk 号点进行转移的话,那么 ii 号点到 kk 号点以及 kk 号点到 jj 号点都是需要有路径可达的,从而可以选择最小距离
  • 初始化:即选择 0 个站点进行中转时,即 f[0][i][j] 的情况中,
    • 如果 ii 号点与 jj 号点自环,则取 00
    • 如果 ii 号点与 jj 号点之间有边,则取重边的最小值
    • 如果 ii 号点与 jj 号点之间无边,则初始化为正无穷
  • 答案状态:对于 aa 号点到 bb 号点之间的最小路径长度,就是 f[n][a][b]
  • 时间复杂度:O(n3)O(n^3)
  • 空间复杂度:O(n3)O(n^3)

空间优化推导:我们尝试优化掉记忆数组的第一维度

  • 对于不选择的情况:由于决策局面 kk 是从前往后枚举,故当前状态 f[k][i][j] 可以直接依赖于已经更新出来且不会被当前状态之后的状态再次覆盖的最优子结构 f[i][j]。即上一个局面的选择情况,就是不选择第 kk 个顶点的情况

  • 对于选择的情况:如果删除第一维度,我们担心的是当前状态 f[k][i][j] 依赖的两个状态 f[i][k]f[k][j] 会不会被后续覆盖掉,即我们不确定 f[i][k]f[k][j] 是否是当前第 k 个局面的最优子结构。尝试推导:

    为了确定 f[i][k]f[k][j] 是否是当前第 kk 个局面的最优子结构,其实就是确定对于当前第 kk 个局面,这两个状态会不会在当前状态 f[i][j] 之后被更新覆盖,那么我们就看这两个状态是从哪里转移过来进行更新的。如果 f[i][k]f[k][j] 这两个状态的转移会依赖于当前状态之后的状态,那么删除第一维度就是错误的,反之就是成立的。

    尝试推导 f[i][k]f[k][j] 从何转移更新:利用我们未删除维度时正确的状态转移方程进行推演

    我们知道:f[k][i][k] = min(f[k-1][i][k], f[k-1][i][k] + f[k-1][k][k]),其中的 f[k-1][k][k] 就是一个自环的路径长度,由于 floydfloyd 算法的约束条件是没有负环,因此 f[k-1][k][k] 一定大于零,故 f[k][i][k] 一定取前者,即 f[k][i][k] = f[k-1][i][k]

    同理可知:

    f[k][k][j] = f[k-1][k][j]

    基于上述推导我们可以知道,当前第 kk 个决策局面中的 f[k][i][k]f[k][k][j] 是依赖于上一个决策局面 k1k-1 的,也就是说这两个状态一定是早于当前状态 f[i][j] 被更新覆盖的,故 f[i][k]f[k][j] 就是当前第 kk 个局面的最优子结构,证毕,可以进行维度的删除

  • 时间复杂度:O(n3)O(n^3)

  • 空间复杂度:O(n2)O(n^2)

不优化空间

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
#include <bits/stdc++.h>
using namespace std;

const int N = 210, INF = 0x3f3f3f3f;

int n, m, Q;
int f[N][N][N];

int main() {
cin >> n >> m >> Q;

// init
memset(f, INF, sizeof f);

// add edges and generate base
while (m--) {
int a, b, w;
cin >> a >> b >> w;
if (a == b) continue; // 重边就不赋值
else if (f[0][a][b] == INF) f[0][a][b] = w; // 第一次加边则直接赋值
else f[0][a][b] = min(f[0][a][b], w); // 再次赋边权就取最小值
}

// generate base again
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++)
if (i == j)
f[0][i][j] = 0; // 自环取边权为 0

// dp
for (int k = 1; k <= n; k++)
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++) {
// 不选第k个顶点
f[k][i][j] = f[k - 1][i][j];

// 选择第k个顶点
if (f[k - 1][i][k] != INF && f[k - 1][k][j] != INF)
f[k][i][j] = min(f[k][i][j], f[k - 1][i][k] + f[k - 1][k][j]);
}

// query
while (Q--) {
int a, b;
cin >> a >> b;
if (f[n][a][b] == INF) cout << "impossible\n";
else cout << f[n][a][b] << "\n";
}

return 0;
}

优化空间

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#include <bits/stdc++.h>
using namespace std;

const int N = 210, INF = 0x3f3f3f3f;

int n, m, Q;
int f[N][N];

int main() {
cin >> n >> m >> Q;

// init
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++)
if (i == j) f[i][j] = 0;
else f[i][j] = INF;

// base
while (m--) {
int a, b, w;
cin >> a >> b >> w;
if (a == b) continue;
else if (f[a][b] == INF) f[a][b] = w;
else f[a][b] = min(f[a][b], w);
}

// dp
for (int k = 1; k <= n; k++)
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++)
if (f[i][k] != INF && f[k][j] != INF)
f[i][j] = min(f[i][j], f[i][k] + f[k][j]);

// query
while (Q--) {
int a, b;
cin >> a >> b;
if (f[a][b] == INF) cout << "impossible\n";
else cout << f[a][b] << "\n";
}

return 0;
}

【最短路】关闭分部的可行集合数目

https://leetcode.cn/problems/number-of-possible-sets-of-closing-branches/

标签:二进制枚举、最短路

题意:给定一个含有 nn 个顶点的无向图,如何删点可以使得剩余的图中顶点两两可达且最大距离不超过 maxDistance?返回所有删点的方案数。

思路:由于 nn 的数据范围只有 1101 \to 10,我们可以直接枚举所有的删点方案。那么如何检查一个方案的合法性呢?直接使用最短路算法检查「所有顶点到每一个顶点」的最远距离即可。这里我们采用朴素 dijkstra 算法。

时间复杂度:O(2n×n3)O(2^n \times n^3) - 其中枚举需要 O(2n)O(2^n)、计算所有顶点到某个顶点的最远距离需要 O(n2)O(n^2)、检查所有顶点需要 O(n)O(n)

[]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
class Solution {
public:
int numberOfSets(int n, int maxDistance, vector<vector<int>>& roads) {
vector<vector<int>> g(n, vector<int>(n, INT_MAX >> 1));
for (auto& r: roads) {
int u = r[0], v = r[1], w = r[2];
g[u][v] = g[v][u] = min(g[u][v], w);
}

auto get_max_dist = [&](int mask, int v) {
vector<bool> SPT(n);
vector<int> d(n, INT_MAX);

d[v] = 0;
SPT[v] = true;

int cnt = 0;
for (int i = 0; i < n; i++) {
if (mask & (1 << i) && !SPT[i]) {
cnt++;
d[i] = min(d[i], d[v] + g[v][i]);
}
}

for (int k = 1; k <= cnt - 1; k++) {
int vex = -1;
for (int i = 0; i < n; i++) {
if (mask & (1 << i) && !SPT[i] && (vex == -1 || d[i] < d[vex])) {
vex = i;
}
}
SPT[vex] = true;
for (int i = 0; i < n; i++) {
if (mask & (1 << i) && !SPT[i]) {
d[i] = min(d[i], d[vex] + g[vex][i]);
}
}
}

int max_dist = -1;
for (int i = 0; i < n; i++) {
if (mask & (1 << i)) {
max_dist = max(max_dist, d[i]);
}
}

return max_dist;
};

int res = 0;
for (int mask = 0; mask < 1 << n; mask++) {
bool ok = true;
for (int i = 0; i < n; i++) {
if (mask & (1 << i) && get_max_dist(mask, i) > maxDistance) {
ok = false;
break;
}
}
res += ok;
}

return res;
}
};
[]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
class Solution:
def numberOfSets(self, n: int, maxDistance: int, roads: List[List[int]]) -> int:
g = [[10 ** 6 for _ in range(n)] for _ in range(n)]
for u, v, w in roads:
g[u][v] = g[v][u] = min(g[u][v], w)

def get_max_dist(mask: int, v: int):
SPT = [False for _ in range(n)]
d = [10 ** 6 for _ in range(n)]

SPT[v] = True
d[v] = 0

cnt = 0
for i in range(n):
if mask & (1 << i) and not SPT[i]:
cnt += 1
d[i] = min(d[i], d[v] + g[v][i])

for _ in range(cnt - 1):
vex = -1
for i in range(n):
if mask & (1 << i) and not SPT[i] and (vex == -1 or d[i] < d[vex]):
vex = i
SPT[vex] = True
for i in range(n):
if mask & (1 << i) and not SPT[i]:
d[i] = min(d[i], d[vex] + g[vex][i])

max_dist = -1
for i in range(n):
if mask & (1 << i):
max_dist = max(max_dist, d[i])

return max_dist

res = 0
for mask in range(1 << n):
ok = True
for i in range(n):
if mask & (1 << i) and get_max_dist(mask, i) > maxDistance:
ok = False
break
res += ok

return res

【思维/遍历】图的遍历

https://www.luogu.com.cn/problem/P3916

题意:给定一个有向图,求解每一个点可以到达的编号最大的点

思路:如果从正向考虑,很显然的一个暴力方法就是对于每一个点都跑一遍 dfs 或者 bfs 获取可达的最大点编号,时间复杂度 O(n2)O(n^2),如果想要在遍历的过程中同时更新其余的点,那只有起点到最大点之间的点可以被更新,可以通过递归时记录路径点进行,时间复杂度几乎不变。我们尝试反向考虑:反向建边。既然正向考虑时需要标记的点为最大点与起点的路径,那不如直接从最大值点开始遍历搜索,在将所有的边全部反向以后,从最大值点开始遍历图,这样就可以在线性时间复杂度内解决问题

时间复杂度:O(n+m)O(n+m)

bfs 代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
#include <iostream>
#include <queue>
#include <cstring>
#include <vector>
using namespace std;

const int N = 100010;

int n, m;
vector<int> g[N], res(N);

void bfs(int now) {
queue<int> q;

res[now] = now;
q.push(now);

while (q.size()) {
int h = q.front();
q.pop();
for (auto& ch: g[h]) {
if (!res[ch]) {
res[ch] = now;
q.push(ch);
}
}
}
}

void solve() {
cin >> n >> m;
while (m--) {
int a, b;
cin >> a >> b;
g[b].push_back(a);
}

for (int i = n; i >= 1; i--) {
if (!res[i]) {
bfs(i);
}
}

for (int i = 1; i <= n; i++) {
cout << res[i] << ' ';
}
}

int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

dfs 代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
#include <iostream>
#include <queue>
#include <cstring>
#include <vector>
using namespace std;

const int N = 100010;

int n, m, val;
vector<int> g[N], res(N);

void dfs(int now) {
res[now] = val;
for (auto& ch: g[now]) {
if (!res[ch]) {
dfs(ch);
}
}
}

void solve() {
cin >> n >> m;
while (m--) {
int a, b;
cin >> a >> b;
g[b].push_back(a);
}

for (int i = n; i >= 1; i--) {
if (!res[i]) {
val = i;
dfs(i);
}
}

for (int i = 1; i <= n; i++) {
cout << res[i] << ' ';
}
}

int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【遍历/并查集】孤立点数量

https://www.acwing.com/problem/content/5560/

题意:给定一个无向图,可能不连通,没有重边和自环。现在需要给图中的每一条无向边定向,要求所有的边定完向以后 0 入度的点尽可能的少,给出最少的 0 入度点的数量

思路:我们知道对于一棵树而言,n 个结点一共有 n-1 条边,也就可以贡献 n-1 个入度,因此至少有一个点的入度为 0。而如果不是一棵树,就会有至少 n 条边,也就至少可以贡献 n 个入度,那么 n 个结点就至少全都有入度了。显然的,一个图至少含有 n 条边时就一定有环。有了上述思路以后就可以发现,这道题本质上就是在判断连通分量是否含有环,如果有环,那么该连通分量定向边以后就不会产生 0 入度的顶点,反之,如果没有环,那么定向边以后最少产生 1 个 0 入度的点。

  • 算法一:遍历图。我们采用 dfs 遍历的方式即可解决问题。一边遍历一边打标记,遇到已经打过标记的非父结点就表明当前连通分量有环。我们使用 C++ 实现

    时间复杂度:O(n+m)O(n+m)

  • 算法二:并查集。由于没有重边,因此在判断每一个连通分量是否含有环时,可以直接通过该连通分量中点和边的数量关系得到结果。我们使用 Python 和 JavaScript 实现

    时间复杂度:O(n)O(n)

C++

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
// 实现 dfs 算法

#include <iostream>
#include <cstring>
#include <vector>
#include <queue>
#include <stack>
#include <algorithm>
#include <unordered_map>
#include <set>
using namespace std;

const int N = 100010;

int n, m;
vector<int> G[N];
bool vis[N];

void dfs(int fa, int now, bool& hasLoop) {
vis[now] = true;
for (auto& ch: G[now]) {
if (ch != fa) {
if (vis[ch]) hasLoop = true;
else dfs(now, ch, hasLoop);
}
}
}

void solve() {
cin >> n >> m;
while (m--) {
int a, b;
cin >> a >> b;
G[a].push_back(b);
G[b].push_back(a);
}

int res = 0;

for (int i = 1; i <= n; i++) {
if (!vis[i]) {
bool hasLoop = false;
dfs(-1, i, hasLoop);
if (!hasLoop) res++;
}
}

cout << res << "\n";
}

int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

Python

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
# 实现 dsu 算法

p = [_ for _ in range(100010)]


def Find(x: int) -> int:
if x != p[x]: p[x] = Find(p[x])
return p[x]


def solve() -> None:
n, m = map(int, input().split())

edgeNum = [0] * (n + 1) # 每个点的连边数

for _ in range(m):
u, v = map(int, input().split())
edgeNum[u] += 1
edgeNum[v] += 1
p[Find(u)] = Find(v)

union = {}

class node:
def __init__(self):
self.v = self.e = 0

for i in range(1, n + 1):
nowp = Find(i)
if nowp not in union: union[nowp] = node()

union[nowp].v += 1
union[nowp].e += edgeNum[i]

res = 0

for comp in union:
if union[comp].e >> 1 == union[comp].v - 1:
res += 1

print(res)


if __name__ == "__main__":
solve()

JavaScript

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
// 实现 dsu 算法

const readline = require('readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});

let n = null;
let m = null;
let p = [], edgeNum = [];

rl.on('line', line => {
const [a, b] = line.split(' ').map(i => Number(i));
if (n === null) {
n = a;
m = b;
for (let i = 1; i <= n; i++) {
p[i] = i;
edgeNum[i] = 0;
}
} else {
edgeNum[a]++;
edgeNum[b]++;
p[Find(a)] = Find(b);
}
});

rl.on('close', () => {
const res = solve();
console.log(res);
});

function Find(x) {
if (x != p[x]) p[x] = Find(p[x]);
return p[x];
}

function solve() {
let res = 0;

// 自定义结构体
class Node {
constructor() {
this.v = 0;
this.e = 0;
}
}

/*
另一种结构体定义方法
function Node() {
this.v = 0;
this.e = 0;
}
*/

// 哈希
let union = new Map();

for (let i = 1; i <= n; i++) {
let nowp = Find(i); // 当前结点的祖宗结点 nowp
if (!union.has(nowp)) union.set(nowp, new Node());

union.get(nowp).v += 1;
union.get(nowp).e += edgeNum[i];
}

// 判断
for (let i of union.keys()) {
if (union.get(i).e >> 1 === union.get(i).v - 1) {
res++;
}
}

return res;
}

【LCA】树的直径

https://www.acwing.com/problem/content/5563/

题意:给定一棵树,初始时含有 4 个结点分别为 1 到 4,其中 1 号为根结点,2 到 4 均为根结点的叶子结点。现在进行 Q 次操作,每次指定一个已经存在的结点向其插入两个新结点作为叶节点。现在需要在每次操作以后输出这棵树的直径。我们定义树的直径为:树中距离最远的两个点之间的距离。

思路一:暴力搜索。

  • 我们将树重构为无向图,对于每一个状态的无向图,首先从任意一个已存在的结点 A 开始搜索到距离他最远的点 B,然后从 B 点出发搜索到离他最远的点 C,则 B 与 C 之间的距离就是当前状态的树的直径。由于每一个状态的树都要遍历两遍树,于是时间复杂度就是平方阶

  • 时间复杂度:O(qn)O(qn)

思路二:最近公共祖先 LCA。

  • 从树的直径出发。我们知道,树的直径由直径的两个结点之间的距离决定,因此我们着眼于这两个结点 AABB 展开。不妨设当前局面直径的两个结点已知为 AABB,现在插入两个叶子结点 L1L_1L2L_2。是否改变了树的直径大小取决于新插入的两个结点对于当前树的影响情况。如果 L1L_1L2L_2 可以替代 AABB,则树的直径就会改变。很显然新插入的两个叶子结点对于直径的两个端点影响是同效果的,因此我们统称新插入的叶子结点为 LL

  • 什么时候树的直径会改变?对于 AABBLL 来说,直径是否改变取决于 LL 能否替代 AABB,一共有六种情况。我们记 dist(A,L)=da\text{dist}(A,L)=dadist(B,L)=db\text{dist}(B,L)=db,当前树的直径为 resres,六种情况如下:

    1. max(da,db)res\text{max}(da, db) \le \text{res},交换 AABB 得到 22
    2. min(da,db)res\text{min}(da,db) \ge \text{res},交换 AABB 得到 22
    3. max(da,db)>res,min(da,db)<res\text{max}(da,db) >res,\text{min}(da,db) < \text{res},交换 AABB 得到 22​ 种

    如图:我们只需要在其中的最大值严格超过当前树的直径 res\text{res} 时更新直径对应的结点以及直径的长度即可

    六种情况

  • 如何快速计算树上任意两个点之间的距离?我们可以使用最近公共祖先 LCA 算法。则树上任意两点 x,yx,y 之间的距离 dist(x,y)\text{dist}(x,y) 为:

    dist(x,y)=dist(x,root)+dist(y,root)2×dist(lca(x,y),root)\text{dist}(x,y) = \text{dist}(x,root) + \text{dist}(y,root) - 2 \times \text{dist}(\text{lca}(x,y),root)

  • 时间复杂度:O(qlogn)O(q \log n)

暴力搜索代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
#include <iostream>
#include <cstring>
#include <vector>
#include <queue>
#include <stack>
#include <algorithm>
#include <unordered_map>
#include <set>
using namespace std;

const int N = 500010;

vector<int> g[N];
int d[N];
bool vis[N];
pair<int, int> res; // first 为最远距离;second 为对应结点编号

void dfs(int pre, int now) {
if (vis[now]) return;

vis[now] = true;

if (pre != -1) {
d[now] = d[pre] + 1;
if (d[now] > res.first) {
res = {d[now], now};
}
}

for (auto& ch: g[now]) {
dfs(now, ch);
}
}

void solve() {
// init
for (int i = 2; i <= 4; i++) {
g[1].push_back(i);
g[i].push_back(1);
}

int now = 4;

int Q;
cin >> Q;
while (Q--) {
int id;
cin >> id;

g[id].push_back(++now);
g[now].push_back(id);

g[id].push_back(++now);
g[now].push_back(id);

res = {-1, -1};

// 第一趟
memset(vis, false, sizeof vis);
memset(d, 0, sizeof d);
d[1] = 0;
dfs(-1, 1);

// 第二趟
memset(vis, false, sizeof vis);
memset(d, 0, sizeof d);
d[res.second] = 0;
dfs(-1, res.second);

cout << res.first << "\n";
}
}

int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

LCA 代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
#include <iostream>
#include <cstring>
#include <vector>
#include <queue>
#include <stack>
#include <algorithm>
#include <unordered_map>
#include <set>
using namespace std;

const int N = 1000010, M = 20;

int d[N]; // d[i] 表示 i 号点到根结点的距离
int to[N][M]; // to[i][j] 表示 i 号点向上跳 2^j 步后到达的结点编号

int lca(int a, int b) {
if (d[a] < d[b]) swap(a, b);

for (int k = M - 1; k >= 0; k--)
if (d[to[a][k]] >= d[b])
a = to[a][k];

if (a == b) return a;

for (int k = M - 1; k >= 0; k--)
if (to[a][k] != to[b][k])
a = to[a][k], b = to[b][k];

return to[a][0];
}

int dist(int a, int b) {
return d[a] + d[b] - 2 * d[lca(a, b)];
}

void solve() {
int Q;
cin >> Q;

// init lca
for (int i = 2; i <= 4; i++) {
d[i] = 1;
to[i][0] = 1;
}

int A = 2, B = 4, now = 4, res = 2;

while (Q--) {
int fa;
cin >> fa;

int L1 = ++now, L2 = ++now;

// upd lca
d[L1] = d[fa] + 1;
d[L2] = d[fa] + 1;
to[L1][0] = fa;
to[L2][0] = fa;
for (int k = 1; k <= M - 1; k++) {
to[L1][k] = to[ to[L1][k-1] ][ k-1 ];
to[L2][k] = to[ to[L2][k-1] ][ k-1 ];
}

int da = dist(A, L1), db = dist(B, L1);

if (max(da, db) <= res) res = res;
else if (min(da, db) >= res) {
if (da > db) res = da, B = L1;
else res = db, A = L1;
} else {
if (da > db) res = da, B = L1;
else res = db, A = L1;
}

cout << res << "\n";
}
}

int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}
]]>
+ 哈希

【哈希】分组

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)))
]]>
@@ -1479,11 +1479,11 @@ - greedy - - /Algorithm/greedy/ + number-theory + + /Algorithm/number-theory/ - 贪心

大胆猜测,小心求证(不会证也没事,做下一题吧)。证明方法总结了以下几种

  • 反证法:假设取一种方案比贪心方案更好,得出相反的结论
  • 边界法:从边界开始考虑,因为满足边界条件更加容易枚举,从而进行后续的贪心
  • 直觉法:遵循社会法则()

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;
}
]]>
+ 数论

整数问题。

【质数】Divide and Equalize

题意:给定 nn 个数,问能否找到一个数 numnum,使得 numn=i=1nainum^n = \prod_{i=1}^{n}a_i

原始思路:起初我的思路是二分,我们需要寻找一个数使得n个数相乘为原数组所有元素之积,那么我们预计算出所有数之积,并且在数组最大值和最小值之间进行二分,每次二分出来的数计算n次方进行笔比较即可。但是有一个很大的问题是,数据量是 10410^4,而数字最大为 10610^6,最大之积为 101010^{10} 吗?不是!最大之和才是,最大之积是 106×10410^{6\times10^4}

最终思路:我们可以将选数看做多个水池匀水的过程。现在每一个水池的水高都不定,很显然我们一定可以一个值使得所有的水池的高度一致,即 i=1nain\frac{\sum_{i=1}^{n}a_i}{n}。但是我们最终的数字是一个整数,显然不可以直接求和然后除以n,那么应该如何分配呢?我们知道水池之所以可以直接除以n,是因为水的最小分配单位是无穷小,可以随意分割;而对于整数而言,最小分配单位是什么呢?答案是质因子!为了通过分配最小单位使得最终的“水池高度一致”,我们需要让每一个“水池”获得的数值相同的质因子数量相同。于是我们只需要统计一下数组中所有数的质因子数量即可。如果对于每一种质因子的数量都可以均匀分配每一个数(即数量是n的整数倍),那么就一定可以找到这个数使得 numn=i=1nainum^n = \prod_{i=1}^{n}a_i

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
void solve() {
int n;
cin >> n;

// 统计所有数字的所有质因数
unordered_map<int, int> m;
for (int i = 0; i < n; i++) {
int x;
cin >> x;

for (int k = 2; k <= x / k; k++) {
if (x % k == 0) {
while (x % k == 0) {
m[k]++;
x /= k;
}
}
}

if (x > 1) {
m[x]++;
}
}

// 查看每一种质因数是否是n的整数倍
for (auto& x: m) {
if (x.second % n) {
cout << "No\n";
return;
}
}

cout << "Yes\n";
}

【整除】Deja Vu

https://codeforces.com/contest/1891/problem/B

题意:给点序列 a 和 b,对于 b 中的每一个元素 bib_i,如果 a 中的元素 aja_j 能够整除 2bi2^{b_i},则将 aja_j 加上 2bi12^{b_i - 1}。给出最后的 a 序列

思路一:暴力枚举。

  • 我们很容易就想到暴力的做法,即两层循环,第一层枚举 b 中的元素,第二层枚举 a 中的元素,如果 a 中的元素能够整除 2 的 bib^i 次方,就将 a 中相应的元素加上一个值即可。但是时间复杂度肯定过不了,考虑优化。

  • 时间复杂度:O(nm)O(nm)

思路二:整除优化。

  • 现在我们假设a中有一个数 aja_j2bi2^{b_i} 的整数倍(其中 bib_i 是b序列中第一个枚举到的能够让 aia_i 整除的数),那么就有 aj=k2bi(k=1,2,...,)a_j = k2^{b_i}(k=1,2,...,),那么 aja_j 就要加上 2bi12^{b_i-1},于是 aja_j 就变为了 k2bi+2bi1=(2k+1)2bi1k2^{b_i}+2^{b_i-1}=(2k+1)2^{b_i-1}。此后 aja_j 就一定是 2t(t[1,bi1])2^t(t\in \left[ 1,b_i-1 \right]) 的倍数。因此我们需要做的就是首先找到b序列中第一个数x,能够在a中找到数是 2x2^x 的整数倍。这一步可以这样进行:对于 a中的每一个数,我们进行30次循环统计当前数是否是 2i2^i 的倍数,如果是就用哈希表记录当前的 ii。最后我们在遍历寻找 x 时,只需要查看当前的 x 是否被哈希过即可。接着我们统计b序列中从x开始的严格降序序列c(由题意知,次序列的数量一定 \le 30,因为b序列中数值的值域为 [1 30][1~30])。最后我们再按照原来的思路,双重循环 a 序列和 c 序列即可。

  • 时间复杂度:O(30n)O(30n)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
void solve() {
int n, m;
cin >> n >> m;

vector<int> a(n + 1), b(m + 1);
unordered_map<int, int> ha;

// 边读边哈希
for (int i = 1; i <= n; i++) {
cin >> a[i];
for (int j = 30; j >= 1; j--) {
if (a[i] % (1 << j) == 0) {
ha[j]++;
}
}
}

for (int i = 1; i <= m; i++) {
cin >> b[i];
}

// 寻找b中第一个能够让a[j]整除的数b[flag]
int flag = -1;
for (int i = 1; i <= m; i++) {
if (ha[b[i]]) {
flag = i;
break;
}
}

// 特判
if (flag == -1) {
for (int j = 1; j <= n; j++) {
cout << a[j] << " \n"[j == n];
}
return;
}

// 寻找b中从flag开始的严格单调递减的序列c
vector<int> c;
c.push_back(b[flag]);
for (; flag <= m; flag++) {
if (b[flag] < c.back()) {
c.push_back(b[flag]);
}
}

// 暴力循环一遍即可
for (int j = 1; j <= n; j++) {
for (int k = 0; k < c.size(); k++) {
if (a[j] % (1 << c[k]) == 0) {
a[j] += 1 << (c[k] - 1);
}
}
}

for (int j = 1; j <= n; j++) {
cout << a[j] << " \n"[j == n];
}
}

【组合数学】序列数量

https://www.acwing.com/problem/content/5571/

题意:给定 i(i=1,2,,n)i(i=1,2,\cdots,n) 个苹果,将其分给 mm 个人,问一共有多少种分配方案,给出结果对 106+310^6+3 取模的结果

思路:整数分配问题。我们采用隔板法,隔板法相关例题见这篇博客:https://www.acwing.com/solution/content/241669/。下面开始讲解

  • 利用隔板法推导结果。首先我们考虑当前局面,即只有 i 个苹果的情况下的方案数。于是题目就是 i 个苹果分给 m 个人,允许分到 0 个。于是借鉴上述链接中“少分型”的思路,先借 m 个苹果,那么此时局面中就有 i+m 个苹果,现在就等价于将 i+m 个苹果分给 m 个人,每人至少分得 1 个苹果。(分完以后每个人都还回去就行了),此时的隔板操作就是”标准型”,即 i+m 个苹果产生 i+m-1 个间隔,在其中插 m-1 块板,从而将划分出来的 m 个部分分给 m 个人。此时的划分方案就是 Ci+m1m1C_{i+m-1}^{m-1},那么对于所有的 i,结果就是

    i=1nCi+m1m1=Cmm1+Cm+1m1++Cn+m1m1=Cm1+Cm+12++Cn+m1n=(Cm0+Cm0)+Cm1+Cm+12++Cn+m1n=Cm0+(Cm0+Cm1+Cm+12++Cn+m1n)=1+(Cm+11+Cm+12++Cn+m1n)=1+(Cm+22++Cn+m1n)=1+Cn+mn\begin{aligned}\sum_{i=1}^n C_{i+m-1}^{m-1} &= C_{m}^{m-1} + C_{m+1}^{m-1} + \cdots + C_{n+m-1}^{m-1} \\&= C_{m}^{1} + C_{m+1}^{2} + \cdots + C_{n+m-1}^{n} \\&= (-C_{m}^{0}+C_{m}^{0})+C_{m}^{1} + C_{m+1}^{2} + \cdots + C_{n+m-1}^{n} \\&= -C_{m}^{0}+(C_{m}^{0}+C_{m}^{1} + C_{m+1}^{2} + \cdots + C_{n+m-1}^{n}) \\&= -1+(C_{m+1}^{1} + C_{m+1}^{2} + \cdots + C_{n+m-1}^{n}) \\&= -1+(C_{m+2}^{2} + \cdots + C_{n+m-1}^{n}) \\&= -1+C_{n+m}^n\end{aligned}

  • 利用乘法逆元计算组合数。结果已经知道了,现在如何计算上述表达式呢?由于 n×mn\times m 超过了常规递推的组合数计算方法内存空间,因此我们采用乘法逆元的思路计算。值得一提的是,本题在计算某个数关于 1e6+3 的乘法逆元时,不需要判断两者是否互质,因为 1e6+3 是一个质数,并且数据范围中的数均小于 1e6+3,因此两数一定互质,可以直接使用费马小定理计算乘法逆元

时间复杂度:O(nlog1e6)O(n\log 1e6)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#include <iostream>
using namespace std;
using ll = long long;

const int N = 7e5 + 10;
const int mod = 1e6 + 3;

int fact[N], infact[N];

int qmi(int a, int b, int p) {
int res = 1 % p;
while (b) {
if (b & 1) res = (ll)res * a % p;
a = (ll)a * a % p;
b >>= 1;
}
return res;
}

void init() {
fact[0] = 1, infact[0] = 1;
for (int i = 1; i < N; i++) {
fact[i] = (ll)fact[i - 1] * i % mod;
infact[i] = (ll)infact[i - 1] * qmi(i, mod - 2, mod) % mod;
}
}

int main() {
init();

int n, m;
cin >> n >> m;

cout << (ll)fact[n + m] * infact[n] % mod * infact[m] % mod - 1;

return 0;
}
]]>
@@ -1498,11 +1498,11 @@ - hashing - - /Algorithm/hashing/ + graphs + + /Algorithm/graphs/ - 哈希

【哈希】分组

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)))
]]>
+ 图论

【拓扑】有向图的拓扑序列

https://www.acwing.com/problem/content/850/

题意:输出一个图的拓扑序,不存在则输出-1

思路:

  • 首先我们要知道拓扑图的概念,感官上就是一张图可以从一个方向拓展到全图,用数学语言就是:若一个由图中所有点构成的序列 A 满足:对于图中的每条边 (x,y),x 在 A 中都出现在 y 之前,则称 A 是该图的一个拓扑序列
  • 接着我们就想要寻找这样的序列 A 了,可以发现对于每一个可扩展的点,入度一定为0,那么我们就从这些点开始宽搜,将搜到的点的入度-1,即删除这条边,直到最后。如果全图的点的入度都变为了0,则此图可拓扑

时间复杂度:O(n+m)O(n+m)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
#include <bits/stdc++.h>
using namespace std;

const int N = 100010;

int n, m;
vector<int> G[N];

void solve() {
// 建图
cin >> n >> m;
vector<int> d(n + 1, 0);
for (int i = 1; i <= m; i++) {
int a, b;
cin >> a >> b;
d[b]++;
G[a].push_back(b);
}

// 预处理宽搜起始点集
queue<int> q;
for (int i = 1; i <= n; i++)
if (!d[i])
q.push(i);

// 宽搜处理
vector<int> res;
while (q.size()) {
auto h = q.front();
q.pop();
res.push_back(h);

for (auto& ch: G[h]) {
d[ch]--;
if (!d[ch]) q.push(ch);
}
}

// 输出合法拓扑序
if (res.size() == n) {
for (auto& x: res) {
cout << x << " ";
}
} else {
cout << -1 << "\n";
}
}

int main() {
solve();
return 0;
}

【基环树/拓扑】Mad City🔥

https://codeforces.com/contest/1873/problem/H

tag:基环树、拓扑排序

题意:给定一个基环树,现在图上有两个点,分别叫做A,B。现在B想要逃脱A的抓捕,问对于给定的局面,B能否永远逃离A的抓捕

思路:思路很简单,我们只需要分B所在位置的两种情况讨论即可

  1. B不在环上:此时我们记距离B最近的环上的那个点叫 tagtag,我们需要比较的是A到tag点的距离 dAd_A 和B到tag的距离 dBd_B,如果 dB<dAd_B < d_A,则一定可以逃脱,否则一定不可以逃脱
  2. B在环上:此时我们只需要判断当前的A点是否与B的位置重合即可,如果重合那就无法逃脱,反之B一定可以逃脱。

代码实现:

  1. 对于第一种情况,我们需要找到tag点以及计算A和B到tag点的距离,

时间复杂度:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
#include <bits/stdc++.h>
using namespace std;

const int N = 200010;

int n, a, b;
vector<int> G[N];
int rd[N], tag, d[N];
bool del[N], vis[N];

void init() {
for (int i = 1; i <= n; i++) {
G[i].clear(); // 存无向图
rd[i] = 0; // 统计每一个结点的入度
del[i] = false; // 拓扑删点删边时使用
d[i] = 0; // 图上所有点到 tag 点的距离
vis[i] = false; // bfs计算距离时使用
}
}

void topu(int now) {
if (rd[now] == 1) {
rd[now]--;
del[now] = true;
for (auto& ch: G[now]) {
if (del[ch]) continue;
rd[ch]--;
if (now == tag) {
tag = ch;
}
topu(ch);
}
}
}

void bfs() {
queue<int> q;
q.push(tag);
d[tag] = 0;

while (q.size()) {
auto now = q.front();
vis[now] = true;
q.pop();

for (auto& ch: G[now]) {
if (!vis[ch]) {
d[ch] = d[now] + 1;
q.push(ch);
vis[ch] = true;
}
}
}
}

void solve() {
// 初始化
cin >> n >> a >> b;
init();

// 建图
for (int i = 1; i <= n; i++) {
int u, v;
cin >> u >> v;
G[u].push_back(v), rd[v]++;
G[v].push_back(u), rd[u]++;
}

// 拓扑删边 & 缩b点
tag = b;
for (int i = 1; i <= n; i++) {
topu(i);
}

// 判断结果 & 计算距离
if (rd[b] == 2 && a != b) {
// b点在环上
cout << "Yes\n";
} else {
// b不在环上
bfs();
cout << (d[a] > d[b] ? "Yes\n" : "No\n");
}
}

int main() {
ios::sync_with_stdio(false);
cin.tie(0);
int T = 1;
cin >> T;
while (T--) solve();
return 0;
}

【二分图】染色法判定二分图

https://www.acwing.com/problem/content/862/

题意:给定一个无向图,可能有重边和自环。问是否可以构成二分图。

二分图的定义:一个图可以被分成两个点集,每个点集内部没有边相连(可以不是连通图)

思路:利用染色法,遍历每一个连通分量,选择连通分量中的任意一点进行染色扩展

  • 如果扩展到的点没有染过色,则染成与当前点相对的颜色
  • 如果扩展到的点已经被染过色了且染的颜色和当前点的颜色相同,则无法构成二分图(奇数环)

时间复杂度:O(n+e)O(n+e)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
const int N = 100010;

int n, m;
vector<int> G[N], col(N);

bool bfs(int u) {
queue<int> q;
q.push(u);
col[u] = 1;

while (q.size()) {
int now = q.front();
q.pop();
for (auto& ch: G[now]) {
if (!col[ch]) {
col[ch] = -col[now];
q.push(ch);
}
else if (col[ch] == col[now]) {
return false;
}
}
}

return true;
}

void solve() {
cin >> n >> m;
while (m--) {
int u, v;
cin >> u >> v;
G[u].push_back(v);
G[v].push_back(u);
}

// 遍历每一个连通分量
for (int i = 1; i <= n; i++) {
if (!col[i]) {
bool ok = bfs(i);
if (!ok) {
cout << "No\n";
return;
}
}
}

cout << "Yes\n";
}

【最小生成树】Kruskal算法求最小生成树

https://www.acwing.com/problem/content/861/

题意:给定一个无向图,可能含有重边和自环。试判断能否求解其中的最小生成树,如果可以给出最小生成树的权值

思路:根据数据量,可以发现顶点数很大,不适用 PrimPrim 算法,只能用 KruskalKruskal 算法,下面简单介绍一下该算法的流程

  • 自环首先排除 - 显然这条边连接的“两个”顶点是不可能选进 MSTMST
  • 首先将每一个结点看成一个连通分量
  • 接着按照权值将所有的边升序排序后,依次选择
    • 如果选出的这条边的两个顶点不在一个连通分量中,则选择这条边并将两个顶点所在的连通分量合并
    • 如果选出的这条边的两个顶点在同一个连通分量中,则不能选择这条边(否则会使得构造的树形成环)
  • 最后统计选择的边的数量 numnum 进行判断即可
    • num=n1num=n-1,则可以生成最小生成树
    • num<n1num<n-1,则无法生成最小生成树
  • 时间复杂度:O(eloge)O(e\log e)​ - 因为最大的时间开销在对所有的边的权值进行排序上

C++

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

const int N = 100010;

struct edge {
int a, b;
int w;
};

int n, m;
vector<edge> edges;
vector<int> p(N);

int Find(int now) {
if (p[now] != now) {
p[now] = Find(p[now]);
}
return p[now];
}

void solve() {
cin >> n >> m;
for (int i = 1; i <= m; i++) {
int a, b, w;
cin >> a >> b >> w;
if (a == b) {
continue;
}
edges.push_back({a, b, w});
}

// 按照边权升序排序
sort(edges.begin(), edges.end(), [&](edge& x, edge& y) {
return x.w < y.w;
});

// 选边
for (int i = 1; i <= n; i++) {
p[i] = i;
}

int res = 0, num = 0;

for (auto& e: edges) {
int pa = Find(e.a), pb = Find(e.b);
if (pa != pb) {
num++;
p[pa] = pb;
res += e.w;
}

if (num == n - 1) {
break;
}
}

// 特判:选出来的边数无法构成一棵树
if (num < n - 1) {
cout << "impossible\n";
return;
}

cout << res << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

Python

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
def Find(x: int, p: list) -> int:
if p[x] != x: p[x] = Find(p[x], p)
return p[x]

def kruskal(n: int, m: int, edges: list) -> int:
# 按边权对边进行降序排序
edges.sort(key=lambda edge: edge[-1])

# dsu 初始化
p = [None] + [i for i in range(1, n + 1)]

# 选边
cnt = sum = 0
for edge in edges:
if cnt == n - 1: break

pa, pb = Find(edge[0], p), Find(edge[1], p)
if pa != pb:
p[pa] = pb
cnt += 1
sum += edge[2]

return sum if cnt == n - 1 else 0


if __name__ == "__main__":
n, m = map(int, input().split())

edges = []
for i in range(m):
edge = tuple(map(int, input().split()))
edges.append(edge)

res = kruskal(n, m, edges)

if res: print(res)
else: print("impossible")

JavaScript

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
const readline = require('readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});

let n = null;
let m = null;
const edges = [];

rl.on('line', line => {
const [a, b, c] = line.split(' ').map(i => Number(i));
if (n === null) {
n = a;
m = b;
} else {
edges.push([a, b, c]);
}
});

rl.on('close', () => {
const res = kurskal(n, m, edges);
console.log(res === Infinity ? 'impossible' : res);
});

function Find(x, p) {
if (p[x] != x) p[x] = Find(p[x], p);
return p[x];
}

function kurskal(n, m, edges) {
// 对边进行升序排序
edges.sort((a, b) => a[2] - b[2]);

// 初始化 dsu
p = [];
for (let i = 1; i <= n; i++) p[i] = i;

// 选边
let cnt = 0, sum = 0;
for (let [a, b, w] of edges) {
if (cnt == n - 1) {
break;
}

let pa = Find(a, p), pb = Find(b, p);
if (pa !== pb) {
cnt++;
p[pa] = pb;
sum += w;
}
}

if (cnt === n - 1) return sum;
else return Infinity;
}

【最小生成树】Prim算法求最小生成树

https://www.acwing.com/problem/content/860/

题意:给定一个稠密无向图,有重边和自环。求出最小生成树

思路:根据题目的数据量,可以使用邻接矩阵存储的方法配合 PrimPrim 算法求解最小生成树,下面给出该算法的流程

  • 首先明确一下变量的定义:
    • g[i][j] 为无向图的邻接矩阵存储结构
    • MST[i] 表示 ii 号点是否加入了 MSTMST 集合
    • d[i] 表示 i 号点到 MSTMST 集合的最短边长度
  • 自环不存储,重边只保留最短的一条
  • 任选一个点到集合 MSTMST 中,并且更新 dd 数组
  • 选择剩余的 n1n-1 个点,每次选择有以下流程
    • 找到最短边,记录最短边长度 ee 和相应的在 UMSTU-MST 集合中对应的顶点序号 vv
    • vv 号点加入 MSTMST 集合,同时根据此时选出的最短边的长度来判断是否存在最小生成树
    • 根据 vv 号点,更新 dd 数组,即更新在集合 UMSTU-MST 中的点到 MSTMST 集合中的点的交叉边的最短长度
  • 时间复杂度:O(n2)O(n^2)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

const int N = 510;

int n, m;
vector<vector<int>> g(N, vector<int>(N, INT_MAX));
vector<int> d(N, INT_MAX); // d[i]表示i号点到MST集合中的最短边长度
bool MST[N];
int res;

void prim() {
// 选任意一个点到MST中并更新d数组
MST[1] = true;
for (int i = 1; i <= n; i++)
if (!MST[i])
d[i] = min(d[i], g[i][1]);

// 选剩下的n-1个点到MST中
for (int i = 2; i <= n; i++) {
// 1. 找到最短边
int e = INT_MAX, v = -1; // e: 最短边长度,v: 最短边不在MST集合中的顶点
for (int j = 1; j <= n; j++)
if (!MST[j] && d[j] < e)
e = d[j], v = j;

// 2. 加入MST集合
MST[v] = true;
if (e == INT_MAX) {
// 特判无法构造MST的情况
cout << "impossible\n";
return;
} else {
res += e;
}

// 3. 更新交叉边 - 迭代(覆盖更新)
for (int j = 1; j <= n; j++)
if (!MST[j])
d[j] = min(d[j], g[j][v]);
}

cout << res << "\n";
}

void solve() {
cin >> n >> m;
while (m--) {
int a, b, w;
cin >> a >> b >> w;

if (a == b) {
continue;
}

if (g[a][b] == INT_MAX) {
g[a][b] = w;
g[b][a] = w;
} else {
g[a][b] = min(g[a][b], w);
g[b][a] = min(g[b][a], w);
}
}

prim();
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【最短路】Dijkstra求最短路 🔥

朴素版 - https://www.acwing.com/problem/content/851/

堆优化 - https://www.acwing.com/problem/content/852/

题意:给定一个正边权的有向图,可能存在重边与自环,问 11 号点到 nn 号点的最短路径长度是多少,如果不可达就输出 1-1

思路一:朴素版。点数 1n5001\le n \le 500,边数 1m1051 \le m\le 10^5

  • 思路:根据数据量,我们采用邻接矩阵的方式存储「点少边多」的稠密图。我们定义 d[i] 数组表示起点到 i 号点的最短距离。先将起点放入 SPT (Shortest Path Tree) 集合,然后更新所有 V-SPT 中的点到 SPT 集合的最短路径长度。接着循环 n-1 次迭代更新剩余的 n-1 个点,每次迭代的过程中,首先选择距离起点最近的点 vex,然后将该点加入 SPT 集合,最后利用该点更新 V-SPT 集合中和该点有连边的点到起点的最短距离。最终的 d[end] 就是起点 start 到终点 end 的最短距离。

  • 总结:算法整体采用贪心与动态规划的思路。与 Prim\text{Prim} 算法仔细比对可知,其中的贪心过程几乎一致,即每次选择加入 SPT 集合的点均为当前局面 V-SPT 集合中距离起点最近的点。而动态规划的过程体现在,在求解出集合 V-SPT 中到集合 STP 最短距离的点 vex 之后,利用该点对「在 V-SPT 集合且和 vex 点有连边的点 i」更新 d[i] 的过程。更新前的状态都是在之前的子结构下的最优解。

  • 时间复杂度:O(n2)O(n^2)

思路二:堆优化。点数 1n1.5×1051\le n \le 1.5 \times 10^5,边数 1m1.5×1051 \le m \le 1.5 \times 10^5

  • 思路:根据数据量,我们采用邻接表的方式存储「点多边少」的稀疏图。如果采用上述朴素 Dijkstra 算法进行求解必然会因为点数过多而超时,因此我们利用数据结构「堆」进行时间开销上的优化。不难发现朴素 Dijkstra 算法在迭代过程中主要有三部分:

    1. 选择距离起点最近的点 vex。因为需要枚举所有的顶点,因此总的时间复杂度为 O(n2)O(n^2)
    2. 将该点加入 SPT 集合。因为只是简单的打个标记,因此总的时间复杂度为 O(n)O(n)
    3. 利用该点更新 V-SPT 集合中和该点相连的点到起点的最短距离。因为此时枚举的是该点所有的连边,而邻接表的图存储方式无法进行重边的删除,因此最坏情况下会枚举所有的边,时间复杂度为 O(m)O(m)
  • 时间复杂度:

朴素版C++:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
#include <bits/stdc++.h>

using ll = long long;
using namespace std;

int dijkstra_ori(std::vector<std::vector<int>>& g, int start, int end) {
int n = g.size() - 1;
std::vector<int> d(n + 1, INT_MAX >> 1);
std::vector<bool> SPT(n + 1, false);

// update start vex
d[start] = 0;
SPT[start] = true;
for (int i = 1; i <= n; i++) {
if (!SPT[i] && g[start][i] != INT_MAX >> 1) {
d[i] = std::min(d[i], d[start] + g[start][i]);
}
}

// update remain n-1 vex
for (int k = 0; k < n - 1; k++) {
int vex = -1;
for (int i = 1; i <= n; i++) {
if (!SPT[i] && (vex == -1 || d[i] < d[vex])) {
vex = i;
}
}
SPT[vex] = true;
for (int i = 1; i <= n; i++) {
if (!SPT[i] && g[vex][i] != INT_MAX >> 1) {
d[i] = std::min(d[i], d[vex] + g[vex][i]);
}
}
}

return d[end] == INT_MAX >> 1 ? -1 : d[end];
}

void solve() {
int n, m;
cin >> n >> m;

vector<vector<int>> g(n + 1, vector<int>(n + 1, INT_MAX >> 1));

while (m--) {
int u, v, w;
cin >> u >> v >> w;
g[u][v] = min(g[u][v], w);
}

cout << dijkstra_ori(g, 1, n) << "\n";
}

signed main() {
std::ios::sync_with_stdio(false);
std::cin.tie(nullptr);
int T = 1;
// std::cin >> T;
while (T--) solve();
return 0;
}

朴素版Python:

[]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import heapq
from collections import defaultdict
from typing import List, Tuple
import math
from itertools import combinations

II = lambda: int(input())
FI = lambda: float(input())
MII = lambda: tuple(map(int, input().split()))
LII = lambda: list(map(int, input().split()))


def dijkstra_ori(g: List[List[int]], start: int, end: int) -> int:
n = len(g) - 1
d = [10 ** 5] * (n + 1)
SPT = [False] * (n + 1)

d[start] = 0
SPT[start] = True
for i in range(1, n + 1):
if not SPT[i] and g[start][i] != 10 ** 5:
d[i] = min(d[i], d[start] + g[start][i])

for _ in range(n - 1):
vex = -1
for i in range(1, n + 1):
if not SPT[i] and (vex == -1 or d[i] < d[vex]):
vex = i
SPT[vex] = True
for i in range(1, n + 1):
if not SPT[i] and g[vex][i] != 10 ** 5:
d[i] = min(d[i], d[vex] + g[vex][i])

return -1 if d[end] == 10 ** 5 else d[end]


def solve() -> None:
n, m = MII()
g = [[10 ** 5] * (n + 1) for _ in range(n + 1)]
for _ in range(m):
u, v, w = MII()
g[u][v] = min(g[u][v], w)
print(dijkstra_ori(g, 1, n))


if __name__ == '__main__':
T = 1
# T = II()
while T: solve(); T -= 1

堆优化版C++:

1
2
3
4
5
6
```

堆优化版Python:

```python

【最短路】Floyd求最短路

https://www.acwing.com/problem/content/856/

题意:给定一个稠密有向图,可能存在重边与自环,给出多个询问,需要给出每一个询问的两个点之前的最短路径长度

思路:我们采用动态规划的思路。在此使用多阶段决策的方法,即每一个路径状态为选择 1k1\to k 个点的情况下的最短路径长度

  • 状态表示:f[k][i][j] 表示在前 kk 个顶点中进行选择(中转),ii 号点到 jj 号点的最短路径长度
  • 状态转移:对于第 kk 个顶点,我们可以选择中转,也可以不中转。
    • 对于不选择中转的情况:f[k][i][j] = f[k-1][i][j]
    • 对于可选择中转的情况:f[k][i][j] = f[k-1][i][k] + f[k-1][k][j]
    • 在其中取最小值即可,但是有一个注意点:对于第二种情况,选择是有一个约束的:即如果选择了 kk 号点进行转移的话,那么 ii 号点到 kk 号点以及 kk 号点到 jj 号点都是需要有路径可达的,从而可以选择最小距离
  • 初始化:即选择 0 个站点进行中转时,即 f[0][i][j] 的情况中,
    • 如果 ii 号点与 jj 号点自环,则取 00
    • 如果 ii 号点与 jj 号点之间有边,则取重边的最小值
    • 如果 ii 号点与 jj 号点之间无边,则初始化为正无穷
  • 答案状态:对于 aa 号点到 bb 号点之间的最小路径长度,就是 f[n][a][b]
  • 时间复杂度:O(n3)O(n^3)
  • 空间复杂度:O(n3)O(n^3)

空间优化推导:我们尝试优化掉记忆数组的第一维度

  • 对于不选择的情况:由于决策局面 kk 是从前往后枚举,故当前状态 f[k][i][j] 可以直接依赖于已经更新出来且不会被当前状态之后的状态再次覆盖的最优子结构 f[i][j]。即上一个局面的选择情况,就是不选择第 kk 个顶点的情况

  • 对于选择的情况:如果删除第一维度,我们担心的是当前状态 f[k][i][j] 依赖的两个状态 f[i][k]f[k][j] 会不会被后续覆盖掉,即我们不确定 f[i][k]f[k][j] 是否是当前第 k 个局面的最优子结构。尝试推导:

    为了确定 f[i][k]f[k][j] 是否是当前第 kk 个局面的最优子结构,其实就是确定对于当前第 kk 个局面,这两个状态会不会在当前状态 f[i][j] 之后被更新覆盖,那么我们就看这两个状态是从哪里转移过来进行更新的。如果 f[i][k]f[k][j] 这两个状态的转移会依赖于当前状态之后的状态,那么删除第一维度就是错误的,反之就是成立的。

    尝试推导 f[i][k]f[k][j] 从何转移更新:利用我们未删除维度时正确的状态转移方程进行推演

    我们知道:f[k][i][k] = min(f[k-1][i][k], f[k-1][i][k] + f[k-1][k][k]),其中的 f[k-1][k][k] 就是一个自环的路径长度,由于 floydfloyd 算法的约束条件是没有负环,因此 f[k-1][k][k] 一定大于零,故 f[k][i][k] 一定取前者,即 f[k][i][k] = f[k-1][i][k]

    同理可知:

    f[k][k][j] = f[k-1][k][j]

    基于上述推导我们可以知道,当前第 kk 个决策局面中的 f[k][i][k]f[k][k][j] 是依赖于上一个决策局面 k1k-1 的,也就是说这两个状态一定是早于当前状态 f[i][j] 被更新覆盖的,故 f[i][k]f[k][j] 就是当前第 kk 个局面的最优子结构,证毕,可以进行维度的删除

  • 时间复杂度:O(n3)O(n^3)

  • 空间复杂度:O(n2)O(n^2)

不优化空间

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
#include <bits/stdc++.h>
using namespace std;

const int N = 210, INF = 0x3f3f3f3f;

int n, m, Q;
int f[N][N][N];

int main() {
cin >> n >> m >> Q;

// init
memset(f, INF, sizeof f);

// add edges and generate base
while (m--) {
int a, b, w;
cin >> a >> b >> w;
if (a == b) continue; // 重边就不赋值
else if (f[0][a][b] == INF) f[0][a][b] = w; // 第一次加边则直接赋值
else f[0][a][b] = min(f[0][a][b], w); // 再次赋边权就取最小值
}

// generate base again
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++)
if (i == j)
f[0][i][j] = 0; // 自环取边权为 0

// dp
for (int k = 1; k <= n; k++)
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++) {
// 不选第k个顶点
f[k][i][j] = f[k - 1][i][j];

// 选择第k个顶点
if (f[k - 1][i][k] != INF && f[k - 1][k][j] != INF)
f[k][i][j] = min(f[k][i][j], f[k - 1][i][k] + f[k - 1][k][j]);
}

// query
while (Q--) {
int a, b;
cin >> a >> b;
if (f[n][a][b] == INF) cout << "impossible\n";
else cout << f[n][a][b] << "\n";
}

return 0;
}

优化空间

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#include <bits/stdc++.h>
using namespace std;

const int N = 210, INF = 0x3f3f3f3f;

int n, m, Q;
int f[N][N];

int main() {
cin >> n >> m >> Q;

// init
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++)
if (i == j) f[i][j] = 0;
else f[i][j] = INF;

// base
while (m--) {
int a, b, w;
cin >> a >> b >> w;
if (a == b) continue;
else if (f[a][b] == INF) f[a][b] = w;
else f[a][b] = min(f[a][b], w);
}

// dp
for (int k = 1; k <= n; k++)
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++)
if (f[i][k] != INF && f[k][j] != INF)
f[i][j] = min(f[i][j], f[i][k] + f[k][j]);

// query
while (Q--) {
int a, b;
cin >> a >> b;
if (f[a][b] == INF) cout << "impossible\n";
else cout << f[a][b] << "\n";
}

return 0;
}

【最短路】关闭分部的可行集合数目

https://leetcode.cn/problems/number-of-possible-sets-of-closing-branches/

标签:二进制枚举、最短路

题意:给定一个含有 nn 个顶点的无向图,如何删点可以使得剩余的图中顶点两两可达且最大距离不超过 maxDistance?返回所有删点的方案数。

思路:由于 nn 的数据范围只有 1101 \to 10,我们可以直接枚举所有的删点方案。那么如何检查一个方案的合法性呢?直接使用最短路算法检查「所有顶点到每一个顶点」的最远距离即可。这里我们采用朴素 dijkstra 算法。

时间复杂度:O(2n×n3)O(2^n \times n^3) - 其中枚举需要 O(2n)O(2^n)、计算所有顶点到某个顶点的最远距离需要 O(n2)O(n^2)、检查所有顶点需要 O(n)O(n)

[]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
class Solution {
public:
int numberOfSets(int n, int maxDistance, vector<vector<int>>& roads) {
vector<vector<int>> g(n, vector<int>(n, INT_MAX >> 1));
for (auto& r: roads) {
int u = r[0], v = r[1], w = r[2];
g[u][v] = g[v][u] = min(g[u][v], w);
}

auto get_max_dist = [&](int mask, int v) {
vector<bool> SPT(n);
vector<int> d(n, INT_MAX);

d[v] = 0;
SPT[v] = true;

int cnt = 0;
for (int i = 0; i < n; i++) {
if (mask & (1 << i) && !SPT[i]) {
cnt++;
d[i] = min(d[i], d[v] + g[v][i]);
}
}

for (int k = 1; k <= cnt - 1; k++) {
int vex = -1;
for (int i = 0; i < n; i++) {
if (mask & (1 << i) && !SPT[i] && (vex == -1 || d[i] < d[vex])) {
vex = i;
}
}
SPT[vex] = true;
for (int i = 0; i < n; i++) {
if (mask & (1 << i) && !SPT[i]) {
d[i] = min(d[i], d[vex] + g[vex][i]);
}
}
}

int max_dist = -1;
for (int i = 0; i < n; i++) {
if (mask & (1 << i)) {
max_dist = max(max_dist, d[i]);
}
}

return max_dist;
};

int res = 0;
for (int mask = 0; mask < 1 << n; mask++) {
bool ok = true;
for (int i = 0; i < n; i++) {
if (mask & (1 << i) && get_max_dist(mask, i) > maxDistance) {
ok = false;
break;
}
}
res += ok;
}

return res;
}
};
[]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
class Solution:
def numberOfSets(self, n: int, maxDistance: int, roads: List[List[int]]) -> int:
g = [[10 ** 6 for _ in range(n)] for _ in range(n)]
for u, v, w in roads:
g[u][v] = g[v][u] = min(g[u][v], w)

def get_max_dist(mask: int, v: int):
SPT = [False for _ in range(n)]
d = [10 ** 6 for _ in range(n)]

SPT[v] = True
d[v] = 0

cnt = 0
for i in range(n):
if mask & (1 << i) and not SPT[i]:
cnt += 1
d[i] = min(d[i], d[v] + g[v][i])

for _ in range(cnt - 1):
vex = -1
for i in range(n):
if mask & (1 << i) and not SPT[i] and (vex == -1 or d[i] < d[vex]):
vex = i
SPT[vex] = True
for i in range(n):
if mask & (1 << i) and not SPT[i]:
d[i] = min(d[i], d[vex] + g[vex][i])

max_dist = -1
for i in range(n):
if mask & (1 << i):
max_dist = max(max_dist, d[i])

return max_dist

res = 0
for mask in range(1 << n):
ok = True
for i in range(n):
if mask & (1 << i) and get_max_dist(mask, i) > maxDistance:
ok = False
break
res += ok

return res

【思维/遍历】图的遍历

https://www.luogu.com.cn/problem/P3916

题意:给定一个有向图,求解每一个点可以到达的编号最大的点

思路:如果从正向考虑,很显然的一个暴力方法就是对于每一个点都跑一遍 dfs 或者 bfs 获取可达的最大点编号,时间复杂度 O(n2)O(n^2),如果想要在遍历的过程中同时更新其余的点,那只有起点到最大点之间的点可以被更新,可以通过递归时记录路径点进行,时间复杂度几乎不变。我们尝试反向考虑:反向建边。既然正向考虑时需要标记的点为最大点与起点的路径,那不如直接从最大值点开始遍历搜索,在将所有的边全部反向以后,从最大值点开始遍历图,这样就可以在线性时间复杂度内解决问题

时间复杂度:O(n+m)O(n+m)

bfs 代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
#include <iostream>
#include <queue>
#include <cstring>
#include <vector>
using namespace std;

const int N = 100010;

int n, m;
vector<int> g[N], res(N);

void bfs(int now) {
queue<int> q;

res[now] = now;
q.push(now);

while (q.size()) {
int h = q.front();
q.pop();
for (auto& ch: g[h]) {
if (!res[ch]) {
res[ch] = now;
q.push(ch);
}
}
}
}

void solve() {
cin >> n >> m;
while (m--) {
int a, b;
cin >> a >> b;
g[b].push_back(a);
}

for (int i = n; i >= 1; i--) {
if (!res[i]) {
bfs(i);
}
}

for (int i = 1; i <= n; i++) {
cout << res[i] << ' ';
}
}

int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

dfs 代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
#include <iostream>
#include <queue>
#include <cstring>
#include <vector>
using namespace std;

const int N = 100010;

int n, m, val;
vector<int> g[N], res(N);

void dfs(int now) {
res[now] = val;
for (auto& ch: g[now]) {
if (!res[ch]) {
dfs(ch);
}
}
}

void solve() {
cin >> n >> m;
while (m--) {
int a, b;
cin >> a >> b;
g[b].push_back(a);
}

for (int i = n; i >= 1; i--) {
if (!res[i]) {
val = i;
dfs(i);
}
}

for (int i = 1; i <= n; i++) {
cout << res[i] << ' ';
}
}

int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【遍历/并查集】孤立点数量

https://www.acwing.com/problem/content/5560/

题意:给定一个无向图,可能不连通,没有重边和自环。现在需要给图中的每一条无向边定向,要求所有的边定完向以后 0 入度的点尽可能的少,给出最少的 0 入度点的数量

思路:我们知道对于一棵树而言,n 个结点一共有 n-1 条边,也就可以贡献 n-1 个入度,因此至少有一个点的入度为 0。而如果不是一棵树,就会有至少 n 条边,也就至少可以贡献 n 个入度,那么 n 个结点就至少全都有入度了。显然的,一个图至少含有 n 条边时就一定有环。有了上述思路以后就可以发现,这道题本质上就是在判断连通分量是否含有环,如果有环,那么该连通分量定向边以后就不会产生 0 入度的顶点,反之,如果没有环,那么定向边以后最少产生 1 个 0 入度的点。

  • 算法一:遍历图。我们采用 dfs 遍历的方式即可解决问题。一边遍历一边打标记,遇到已经打过标记的非父结点就表明当前连通分量有环。我们使用 C++ 实现

    时间复杂度:O(n+m)O(n+m)

  • 算法二:并查集。由于没有重边,因此在判断每一个连通分量是否含有环时,可以直接通过该连通分量中点和边的数量关系得到结果。我们使用 Python 和 JavaScript 实现

    时间复杂度:O(n)O(n)

C++

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
// 实现 dfs 算法

#include <iostream>
#include <cstring>
#include <vector>
#include <queue>
#include <stack>
#include <algorithm>
#include <unordered_map>
#include <set>
using namespace std;

const int N = 100010;

int n, m;
vector<int> G[N];
bool vis[N];

void dfs(int fa, int now, bool& hasLoop) {
vis[now] = true;
for (auto& ch: G[now]) {
if (ch != fa) {
if (vis[ch]) hasLoop = true;
else dfs(now, ch, hasLoop);
}
}
}

void solve() {
cin >> n >> m;
while (m--) {
int a, b;
cin >> a >> b;
G[a].push_back(b);
G[b].push_back(a);
}

int res = 0;

for (int i = 1; i <= n; i++) {
if (!vis[i]) {
bool hasLoop = false;
dfs(-1, i, hasLoop);
if (!hasLoop) res++;
}
}

cout << res << "\n";
}

int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

Python

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
# 实现 dsu 算法

p = [_ for _ in range(100010)]


def Find(x: int) -> int:
if x != p[x]: p[x] = Find(p[x])
return p[x]


def solve() -> None:
n, m = map(int, input().split())

edgeNum = [0] * (n + 1) # 每个点的连边数

for _ in range(m):
u, v = map(int, input().split())
edgeNum[u] += 1
edgeNum[v] += 1
p[Find(u)] = Find(v)

union = {}

class node:
def __init__(self):
self.v = self.e = 0

for i in range(1, n + 1):
nowp = Find(i)
if nowp not in union: union[nowp] = node()

union[nowp].v += 1
union[nowp].e += edgeNum[i]

res = 0

for comp in union:
if union[comp].e >> 1 == union[comp].v - 1:
res += 1

print(res)


if __name__ == "__main__":
solve()

JavaScript

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
// 实现 dsu 算法

const readline = require('readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});

let n = null;
let m = null;
let p = [], edgeNum = [];

rl.on('line', line => {
const [a, b] = line.split(' ').map(i => Number(i));
if (n === null) {
n = a;
m = b;
for (let i = 1; i <= n; i++) {
p[i] = i;
edgeNum[i] = 0;
}
} else {
edgeNum[a]++;
edgeNum[b]++;
p[Find(a)] = Find(b);
}
});

rl.on('close', () => {
const res = solve();
console.log(res);
});

function Find(x) {
if (x != p[x]) p[x] = Find(p[x]);
return p[x];
}

function solve() {
let res = 0;

// 自定义结构体
class Node {
constructor() {
this.v = 0;
this.e = 0;
}
}

/*
另一种结构体定义方法
function Node() {
this.v = 0;
this.e = 0;
}
*/

// 哈希
let union = new Map();

for (let i = 1; i <= n; i++) {
let nowp = Find(i); // 当前结点的祖宗结点 nowp
if (!union.has(nowp)) union.set(nowp, new Node());

union.get(nowp).v += 1;
union.get(nowp).e += edgeNum[i];
}

// 判断
for (let i of union.keys()) {
if (union.get(i).e >> 1 === union.get(i).v - 1) {
res++;
}
}

return res;
}

【LCA】树的直径

https://www.acwing.com/problem/content/5563/

题意:给定一棵树,初始时含有 4 个结点分别为 1 到 4,其中 1 号为根结点,2 到 4 均为根结点的叶子结点。现在进行 Q 次操作,每次指定一个已经存在的结点向其插入两个新结点作为叶节点。现在需要在每次操作以后输出这棵树的直径。我们定义树的直径为:树中距离最远的两个点之间的距离。

思路一:暴力搜索。

  • 我们将树重构为无向图,对于每一个状态的无向图,首先从任意一个已存在的结点 A 开始搜索到距离他最远的点 B,然后从 B 点出发搜索到离他最远的点 C,则 B 与 C 之间的距离就是当前状态的树的直径。由于每一个状态的树都要遍历两遍树,于是时间复杂度就是平方阶

  • 时间复杂度:O(qn)O(qn)

思路二:最近公共祖先 LCA。

  • 从树的直径出发。我们知道,树的直径由直径的两个结点之间的距离决定,因此我们着眼于这两个结点 AABB 展开。不妨设当前局面直径的两个结点已知为 AABB,现在插入两个叶子结点 L1L_1L2L_2。是否改变了树的直径大小取决于新插入的两个结点对于当前树的影响情况。如果 L1L_1L2L_2 可以替代 AABB,则树的直径就会改变。很显然新插入的两个叶子结点对于直径的两个端点影响是同效果的,因此我们统称新插入的叶子结点为 LL

  • 什么时候树的直径会改变?对于 AABBLL 来说,直径是否改变取决于 LL 能否替代 AABB,一共有六种情况。我们记 dist(A,L)=da\text{dist}(A,L)=dadist(B,L)=db\text{dist}(B,L)=db,当前树的直径为 resres,六种情况如下:

    1. max(da,db)res\text{max}(da, db) \le \text{res},交换 AABB 得到 22
    2. min(da,db)res\text{min}(da,db) \ge \text{res},交换 AABB 得到 22
    3. max(da,db)>res,min(da,db)<res\text{max}(da,db) >res,\text{min}(da,db) < \text{res},交换 AABB 得到 22​ 种

    如图:我们只需要在其中的最大值严格超过当前树的直径 res\text{res} 时更新直径对应的结点以及直径的长度即可

    六种情况

  • 如何快速计算树上任意两个点之间的距离?我们可以使用最近公共祖先 LCA 算法。则树上任意两点 x,yx,y 之间的距离 dist(x,y)\text{dist}(x,y) 为:

    dist(x,y)=dist(x,root)+dist(y,root)2×dist(lca(x,y),root)\text{dist}(x,y) = \text{dist}(x,root) + \text{dist}(y,root) - 2 \times \text{dist}(\text{lca}(x,y),root)

  • 时间复杂度:O(qlogn)O(q \log n)

暴力搜索代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
#include <iostream>
#include <cstring>
#include <vector>
#include <queue>
#include <stack>
#include <algorithm>
#include <unordered_map>
#include <set>
using namespace std;

const int N = 500010;

vector<int> g[N];
int d[N];
bool vis[N];
pair<int, int> res; // first 为最远距离;second 为对应结点编号

void dfs(int pre, int now) {
if (vis[now]) return;

vis[now] = true;

if (pre != -1) {
d[now] = d[pre] + 1;
if (d[now] > res.first) {
res = {d[now], now};
}
}

for (auto& ch: g[now]) {
dfs(now, ch);
}
}

void solve() {
// init
for (int i = 2; i <= 4; i++) {
g[1].push_back(i);
g[i].push_back(1);
}

int now = 4;

int Q;
cin >> Q;
while (Q--) {
int id;
cin >> id;

g[id].push_back(++now);
g[now].push_back(id);

g[id].push_back(++now);
g[now].push_back(id);

res = {-1, -1};

// 第一趟
memset(vis, false, sizeof vis);
memset(d, 0, sizeof d);
d[1] = 0;
dfs(-1, 1);

// 第二趟
memset(vis, false, sizeof vis);
memset(d, 0, sizeof d);
d[res.second] = 0;
dfs(-1, res.second);

cout << res.first << "\n";
}
}

int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

LCA 代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
#include <iostream>
#include <cstring>
#include <vector>
#include <queue>
#include <stack>
#include <algorithm>
#include <unordered_map>
#include <set>
using namespace std;

const int N = 1000010, M = 20;

int d[N]; // d[i] 表示 i 号点到根结点的距离
int to[N][M]; // to[i][j] 表示 i 号点向上跳 2^j 步后到达的结点编号

int lca(int a, int b) {
if (d[a] < d[b]) swap(a, b);

for (int k = M - 1; k >= 0; k--)
if (d[to[a][k]] >= d[b])
a = to[a][k];

if (a == b) return a;

for (int k = M - 1; k >= 0; k--)
if (to[a][k] != to[b][k])
a = to[a][k], b = to[b][k];

return to[a][0];
}

int dist(int a, int b) {
return d[a] + d[b] - 2 * d[lca(a, b)];
}

void solve() {
int Q;
cin >> Q;

// init lca
for (int i = 2; i <= 4; i++) {
d[i] = 1;
to[i][0] = 1;
}

int A = 2, B = 4, now = 4, res = 2;

while (Q--) {
int fa;
cin >> fa;

int L1 = ++now, L2 = ++now;

// upd lca
d[L1] = d[fa] + 1;
d[L2] = d[fa] + 1;
to[L1][0] = fa;
to[L2][0] = fa;
for (int k = 1; k <= M - 1; k++) {
to[L1][k] = to[ to[L1][k-1] ][ k-1 ];
to[L2][k] = to[ to[L2][k-1] ][ k-1 ];
}

int da = dist(A, L1), db = dist(B, L1);

if (max(da, db) <= res) res = res;
else if (min(da, db) >= res) {
if (da > db) res = da, B = L1;
else res = db, A = L1;
} else {
if (da > db) res = da, B = L1;
else res = db, A = L1;
}

cout << res << "\n";
}
}

int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}
]]>
@@ -1517,11 +1517,11 @@ - number-theory - - /Algorithm/number-theory/ + greedy + + /Algorithm/greedy/ - 数论

整数问题。

【质数】Divide and Equalize

题意:给定 nn 个数,问能否找到一个数 numnum,使得 numn=i=1nainum^n = \prod_{i=1}^{n}a_i

原始思路:起初我的思路是二分,我们需要寻找一个数使得n个数相乘为原数组所有元素之积,那么我们预计算出所有数之积,并且在数组最大值和最小值之间进行二分,每次二分出来的数计算n次方进行笔比较即可。但是有一个很大的问题是,数据量是 10410^4,而数字最大为 10610^6,最大之积为 101010^{10} 吗?不是!最大之和才是,最大之积是 106×10410^{6\times10^4}

最终思路:我们可以将选数看做多个水池匀水的过程。现在每一个水池的水高都不定,很显然我们一定可以一个值使得所有的水池的高度一致,即 i=1nain\frac{\sum_{i=1}^{n}a_i}{n}。但是我们最终的数字是一个整数,显然不可以直接求和然后除以n,那么应该如何分配呢?我们知道水池之所以可以直接除以n,是因为水的最小分配单位是无穷小,可以随意分割;而对于整数而言,最小分配单位是什么呢?答案是质因子!为了通过分配最小单位使得最终的“水池高度一致”,我们需要让每一个“水池”获得的数值相同的质因子数量相同。于是我们只需要统计一下数组中所有数的质因子数量即可。如果对于每一种质因子的数量都可以均匀分配每一个数(即数量是n的整数倍),那么就一定可以找到这个数使得 numn=i=1nainum^n = \prod_{i=1}^{n}a_i

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
void solve() {
int n;
cin >> n;

// 统计所有数字的所有质因数
unordered_map<int, int> m;
for (int i = 0; i < n; i++) {
int x;
cin >> x;

for (int k = 2; k <= x / k; k++) {
if (x % k == 0) {
while (x % k == 0) {
m[k]++;
x /= k;
}
}
}

if (x > 1) {
m[x]++;
}
}

// 查看每一种质因数是否是n的整数倍
for (auto& x: m) {
if (x.second % n) {
cout << "No\n";
return;
}
}

cout << "Yes\n";
}

【整除】Deja Vu

https://codeforces.com/contest/1891/problem/B

题意:给点序列 a 和 b,对于 b 中的每一个元素 bib_i,如果 a 中的元素 aja_j 能够整除 2bi2^{b_i},则将 aja_j 加上 2bi12^{b_i - 1}。给出最后的 a 序列

思路一:暴力枚举。

  • 我们很容易就想到暴力的做法,即两层循环,第一层枚举 b 中的元素,第二层枚举 a 中的元素,如果 a 中的元素能够整除 2 的 bib^i 次方,就将 a 中相应的元素加上一个值即可。但是时间复杂度肯定过不了,考虑优化。

  • 时间复杂度:O(nm)O(nm)

思路二:整除优化。

  • 现在我们假设a中有一个数 aja_j2bi2^{b_i} 的整数倍(其中 bib_i 是b序列中第一个枚举到的能够让 aia_i 整除的数),那么就有 aj=k2bi(k=1,2,...,)a_j = k2^{b_i}(k=1,2,...,),那么 aja_j 就要加上 2bi12^{b_i-1},于是 aja_j 就变为了 k2bi+2bi1=(2k+1)2bi1k2^{b_i}+2^{b_i-1}=(2k+1)2^{b_i-1}。此后 aja_j 就一定是 2t(t[1,bi1])2^t(t\in \left[ 1,b_i-1 \right]) 的倍数。因此我们需要做的就是首先找到b序列中第一个数x,能够在a中找到数是 2x2^x 的整数倍。这一步可以这样进行:对于 a中的每一个数,我们进行30次循环统计当前数是否是 2i2^i 的倍数,如果是就用哈希表记录当前的 ii。最后我们在遍历寻找 x 时,只需要查看当前的 x 是否被哈希过即可。接着我们统计b序列中从x开始的严格降序序列c(由题意知,次序列的数量一定 \le 30,因为b序列中数值的值域为 [1 30][1~30])。最后我们再按照原来的思路,双重循环 a 序列和 c 序列即可。

  • 时间复杂度:O(30n)O(30n)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
void solve() {
int n, m;
cin >> n >> m;

vector<int> a(n + 1), b(m + 1);
unordered_map<int, int> ha;

// 边读边哈希
for (int i = 1; i <= n; i++) {
cin >> a[i];
for (int j = 30; j >= 1; j--) {
if (a[i] % (1 << j) == 0) {
ha[j]++;
}
}
}

for (int i = 1; i <= m; i++) {
cin >> b[i];
}

// 寻找b中第一个能够让a[j]整除的数b[flag]
int flag = -1;
for (int i = 1; i <= m; i++) {
if (ha[b[i]]) {
flag = i;
break;
}
}

// 特判
if (flag == -1) {
for (int j = 1; j <= n; j++) {
cout << a[j] << " \n"[j == n];
}
return;
}

// 寻找b中从flag开始的严格单调递减的序列c
vector<int> c;
c.push_back(b[flag]);
for (; flag <= m; flag++) {
if (b[flag] < c.back()) {
c.push_back(b[flag]);
}
}

// 暴力循环一遍即可
for (int j = 1; j <= n; j++) {
for (int k = 0; k < c.size(); k++) {
if (a[j] % (1 << c[k]) == 0) {
a[j] += 1 << (c[k] - 1);
}
}
}

for (int j = 1; j <= n; j++) {
cout << a[j] << " \n"[j == n];
}
}

【组合数学】序列数量

https://www.acwing.com/problem/content/5571/

题意:给定 i(i=1,2,,n)i(i=1,2,\cdots,n) 个苹果,将其分给 mm 个人,问一共有多少种分配方案,给出结果对 106+310^6+3 取模的结果

思路:整数分配问题。我们采用隔板法,隔板法相关例题见这篇博客:https://www.acwing.com/solution/content/241669/。下面开始讲解

  • 利用隔板法推导结果。首先我们考虑当前局面,即只有 i 个苹果的情况下的方案数。于是题目就是 i 个苹果分给 m 个人,允许分到 0 个。于是借鉴上述链接中“少分型”的思路,先借 m 个苹果,那么此时局面中就有 i+m 个苹果,现在就等价于将 i+m 个苹果分给 m 个人,每人至少分得 1 个苹果。(分完以后每个人都还回去就行了),此时的隔板操作就是”标准型”,即 i+m 个苹果产生 i+m-1 个间隔,在其中插 m-1 块板,从而将划分出来的 m 个部分分给 m 个人。此时的划分方案就是 Ci+m1m1C_{i+m-1}^{m-1},那么对于所有的 i,结果就是

    i=1nCi+m1m1=Cmm1+Cm+1m1++Cn+m1m1=Cm1+Cm+12++Cn+m1n=(Cm0+Cm0)+Cm1+Cm+12++Cn+m1n=Cm0+(Cm0+Cm1+Cm+12++Cn+m1n)=1+(Cm+11+Cm+12++Cn+m1n)=1+(Cm+22++Cn+m1n)=1+Cn+mn\begin{aligned}\sum_{i=1}^n C_{i+m-1}^{m-1} &= C_{m}^{m-1} + C_{m+1}^{m-1} + \cdots + C_{n+m-1}^{m-1} \\&= C_{m}^{1} + C_{m+1}^{2} + \cdots + C_{n+m-1}^{n} \\&= (-C_{m}^{0}+C_{m}^{0})+C_{m}^{1} + C_{m+1}^{2} + \cdots + C_{n+m-1}^{n} \\&= -C_{m}^{0}+(C_{m}^{0}+C_{m}^{1} + C_{m+1}^{2} + \cdots + C_{n+m-1}^{n}) \\&= -1+(C_{m+1}^{1} + C_{m+1}^{2} + \cdots + C_{n+m-1}^{n}) \\&= -1+(C_{m+2}^{2} + \cdots + C_{n+m-1}^{n}) \\&= -1+C_{n+m}^n\end{aligned}

  • 利用乘法逆元计算组合数。结果已经知道了,现在如何计算上述表达式呢?由于 n×mn\times m 超过了常规递推的组合数计算方法内存空间,因此我们采用乘法逆元的思路计算。值得一提的是,本题在计算某个数关于 1e6+3 的乘法逆元时,不需要判断两者是否互质,因为 1e6+3 是一个质数,并且数据范围中的数均小于 1e6+3,因此两数一定互质,可以直接使用费马小定理计算乘法逆元

时间复杂度:O(nlog1e6)O(n\log 1e6)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#include <iostream>
using namespace std;
using ll = long long;

const int N = 7e5 + 10;
const int mod = 1e6 + 3;

int fact[N], infact[N];

int qmi(int a, int b, int p) {
int res = 1 % p;
while (b) {
if (b & 1) res = (ll)res * a % p;
a = (ll)a * a % p;
b >>= 1;
}
return res;
}

void init() {
fact[0] = 1, infact[0] = 1;
for (int i = 1; i < N; i++) {
fact[i] = (ll)fact[i - 1] * i % mod;
infact[i] = (ll)infact[i - 1] * qmi(i, mod - 2, mod) % mod;
}
}

int main() {
init();

int n, m;
cin >> n >> m;

cout << (ll)fact[n + m] * infact[n] % mod * infact[m] % mod - 1;

return 0;
}
]]>
+ 贪心

大胆猜测,小心求证(不会证也没事,做下一题吧)。证明方法总结了以下几种

  • 反证法:假设取一种方案比贪心方案更好,得出相反的结论
  • 边界法:从边界开始考虑,因为满足边界条件更加容易枚举,从而进行后续的贪心
  • 直觉法:遵循社会法则()

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;
}
]]>
@@ -1587,11 +1587,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;
}
]]>
@@ -1625,11 +1625,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

]]>
@@ -1644,11 +1644,11 @@ - divide-and-conquer - - /Algorithm/divide-and-conquer/ + dfs-and-similar + + /Algorithm/dfs-and-similar/ - 分治

将大问题转化为等价小问题进行求解。

【分治】随机排列

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;
}
]]>
+ 搜索

无论是深搜还是宽搜,都逃不掉图的思维。我们将搜索图建立起来之后,剩余的编码过程就会跃然纸上。

【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
]]>
@@ -1685,33 +1685,33 @@ - 心语 + 关于作者 / -

每当独自听着歌,或者看到一些人、一些场景时,脑海中总会有小人在低语,有时倒也能让自己受到启发。但很多想法转瞬即逝,故借此处将一些内容及时记录下来,权当留念。

]]>
+ 教育经历

层次时间学校方向地址高中2019.092022.06江苏省南菁高级中学物化生江苏省无锡市江阴市龙定路 158 号本科2022.092026.06南京师范大学人工智能江苏省南京市栖霞区文苑路 1 号\begin{array}{ccccc}\hline\textbf{层次} & \textbf{时间} & \textbf{学校} & \textbf{方向} & \textbf{地址} \\\hline\text{高中} & 2019.09 - 2022.06 & \text{江苏省南菁高级中学} & \text{物化生} & \text{江苏省无锡市江阴市龙定路 158 号} \\\text{本科} & 2022.09 - 2026.06 & \text{南京师范大学} & \text{人工智能} & \text{江苏省南京市栖霞区文苑路 1 号} \\\hline\end{array}

网瘾青年

dwj601 dwj601

技术栈

技术栈

编程语言

编程语言

开发工具

开发工具]]>
- + 心语 / - +

每当独自听着歌,或者看到一些人、一些场景时,脑海中总会有小人在低语,有时倒也能让自己受到启发。但很多想法转瞬即逝,故借此处将一些内容及时记录下来,权当留念。

]]>
- 关于作者 + / - 教育经历

层次时间学校方向地址高中2019.092022.06江苏省南菁高级中学物化生江苏省无锡市江阴市龙定路 158 号本科2022.092026.06南京师范大学人工智能江苏省南京市栖霞区文苑路 1 号\begin{array}{ccccc}\hline\textbf{层次} & \textbf{时间} & \textbf{学校} & \textbf{方向} & \textbf{地址} \\\hline\text{高中} & 2019.09 - 2022.06 & \text{江苏省南菁高级中学} & \text{物化生} & \text{江苏省无锡市江阴市龙定路 158 号} \\\text{本科} & 2022.09 - 2026.06 & \text{南京师范大学} & \text{人工智能} & \text{江苏省南京市栖霞区文苑路 1 号} \\\hline\end{array}

网瘾青年

dwj601 dwj601

技术栈

技术栈

编程语言

编程语言

开发工具

开发工具]]>
+
diff --git a/page/16/index.html b/page/16/index.html index 2a6fed9f1..3fee8cac6 100644 --- a/page/16/index.html +++ b/page/16/index.html @@ -311,15 +311,15 @@

- - OptMethod + + MachineLearning

- +
- 最优化方法 前言 学科地位: 主讲教师 学分配额 学科类别 王启春 4 专业课 成绩组成: 平时(作业+考勤) 期中(大作业) 期末(闭卷) 20% 30% 50% 教材情况: 课程名称 选用教材 版次 作者 出版社 ISBN号 最优化算法 《最优化方法》 2 孙文瑜 高等教育出版社 978-7-04-029763-8 第一章 基本概念 + 机器学习与模式识别 前言 学科地位: 主讲教师 学分配额 学科类别 杨琬琪 3 专业课 成绩组成: 理论课: 作业+课堂 课堂测验(2次) 期末(闭卷) 30% 20% 50% 实验课: 19次实验(五级制) 100% 教材情况: 课程名称 选用教材 版次 作者 出版社 ISBN号 机器学习与模式识别 《机器学习与模式识别》
@@ -372,15 +372,15 @@

- - MachineLearning + + OptMethod

- +
- 机器学习与模式识别 前言 学科地位: 主讲教师 学分配额 学科类别 杨琬琪 3 专业课 成绩组成: 理论课: 作业+课堂 课堂测验(2次) 期末(闭卷) 30% 20% 50% 实验课: 19次实验(五级制) 100% 教材情况: 课程名称 选用教材 版次 作者 出版社 ISBN号 机器学习与模式识别 《机器学习与模式识别》 + 最优化方法 前言 学科地位: 主讲教师 学分配额 学科类别 王启春 4 专业课 成绩组成: 平时(作业+考勤) 期中(大作业) 期末(闭卷) 20% 30% 50% 教材情况: 课程名称 选用教材 版次 作者 出版社 ISBN号 最优化算法 《最优化方法》 2 孙文瑜 高等教育出版社 978-7-04-029763-8 第一章 基本概念
diff --git a/page/18/index.html b/page/18/index.html index cc807c895..44c9bcfbb 100644 --- a/page/18/index.html +++ b/page/18/index.html @@ -311,15 +311,15 @@

- - DigitalLogicCircuit + + DataStructure

- +
- 联系方式: ✉ lijuncst@njnu.edu.cn ☎ 13770610040 数字逻辑电路 一、数字逻辑概论 1.1 数字信号与数字电路 1.1.1 数字技术的发展及其应用 电流控制器件:电子管、晶体管(二极管、三极管)、半导体集成电路 EDA(Electronic Design Automation)技术(硬件设计软件化):设计:EWB or Verilog、仿真、下载、验证 + score:\mathscr {score:}score: 平时 20%(出勤、作业、实验) 期中 20% 期末 60% 数据结构 完整实现代码:https://github.com/Explorer-Dong/DataStructure 一、绪论 1.1 数据分析+结构存储+算法计算 1.1.1 逻辑结构 对于当前的数据之间的关系进行分析,进而思考应该如何存储,有以下几种逻辑结构:集合、
@@ -372,15 +372,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 数据结构设计与算法思想 本程序涉及到四种排序算法,其中: 希尔排序:通过倍增方法逐渐扩大选择插入数据量的规模,而达到减小数据比较的次数,时间
diff --git a/page/19/index.html b/page/19/index.html index f76a17e90..60bc01297 100644 --- a/page/19/index.html +++ b/page/19/index.html @@ -250,15 +250,15 @@

- - DataStructureClassDesign + + DigitalLogicCircuit

- +
- 数据结构课程设计 效果演示 [!note] 以下为课设报告内容 一、必做题 题目:编程实现希尔、快速、堆排序、归并排序算法。要求随机产生 10000 个数据存入磁盘文件,然后读入数据文件,分别采用不同的排序方法进行排序,并将结果存入文件中。 1.1 数据结构设计与算法思想 本程序涉及到四种排序算法,其中: 希尔排序:通过倍增方法逐渐扩大选择插入数据量的规模,而达到减小数据比较的次数,时间 + 联系方式: ✉ lijuncst@njnu.edu.cn ☎ 13770610040 数字逻辑电路 一、数字逻辑概论 1.1 数字信号与数字电路 1.1.1 数字技术的发展及其应用 电流控制器件:电子管、晶体管(二极管、三极管)、半导体集成电路 EDA(Electronic Design Automation)技术(硬件设计软件化):设计:EWB or Verilog、仿真、下载、验证
diff --git a/page/24/index.html b/page/24/index.html index fcfba7235..ed45474ea 100644 --- a/page/24/index.html +++ b/page/24/index.html @@ -250,15 +250,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中某个点
@@ -304,15 +304,15 @@

- - geometry + + games

- +
- 计算几何 【二维/数学】Minimum Manhattan Distance https://codeforces.com/gym/104639/problem/J 题意:给定两个圆的直径的两个点坐标,其中约束条件是两个圆一定是处在相离的两个角上。问如何在C2圆上或圆内找到一点p,使得点p到C1圆的所有点的曼哈顿距离的期望值最小 思路: 看似需要积分,其实我们可以发现,对于点p到C1中某个点 + 博弈论 思考如何必胜态和必败态是什么以及如何构造这样的局面。 【博弈/贪心/交互】Salyg1n and the MEX Game https://codeforces.com/contest/1867/problem/C 标签:博弈、贪心、交互 题面:对于给定n个数的数列,先手可以放入一个数列中不存在的数(0-1e9),后手可以从数列中拿掉一个数,但是这个数必须严格小于刚才先手放入的数。终止条
@@ -358,15 +358,15 @@

- - graphs + + hashing

- +
- 图论 【拓扑】有向图的拓扑序列 https://www.acwing.com/problem/content/850/ 题意:输出一个图的拓扑序,不存在则输出-1 思路: 首先我们要知道拓扑图的概念,感官上就是一张图可以从一个方向拓展到全图,用数学语言就是:若一个由图中所有点构成的序列 A 满足:对于图中的每条边 (x,y),x 在 A 中都出现在 y 之前,则称 A 是该图的一个拓扑序列 接 + 哈希 【哈希】分组 https://www.acwing.com/problem/content/5182/ 存储不想同组和想同组的人员信息:存入数组,数据类型为一对字符串 存储所有的组队信息:存入哈希表,数据类型为“键:字符串”“值:一对字符串” 想要知道最终的分组情况,只需要查询数组中的队员情况与想同组 or 不想同组的成员名字是否一致即可 时间复杂度 O(n)O(n)O(n),空间复杂度
diff --git a/page/25/index.html b/page/25/index.html index b62608840..049aa5795 100644 --- a/page/25/index.html +++ b/page/25/index.html @@ -250,15 +250,15 @@

- - greedy + + number-theory

- +
- 贪心 大胆猜测,小心求证(不会证也没事,做下一题吧)。证明方法总结了以下几种 反证法:假设取一种方案比贪心方案更好,得出相反的结论 边界法:从边界开始考虑,因为满足边界条件更加容易枚举,从而进行后续的贪心 直觉法:遵循社会法则() 1. green_gold_dog, array and permutation https://codeforces.com/contest/1867/probl + 数论 整数问题。 【质数】Divide and Equalize 题意:给定 nnn 个数,问能否找到一个数 numnumnum,使得 numn=∏i=1nainum^n = \prod_{i=1}^{n}a_inumn=∏i=1n​ai​ 原始思路:起初我的思路是二分,我们需要寻找一个数使得n个数相乘为原数组所有元素之积,那么我们预计算出所有数之积,并且在数组最大值和最小值之间进行二分,每次二
@@ -304,15 +304,15 @@

- - hashing + + graphs

- +
- 哈希 【哈希】分组 https://www.acwing.com/problem/content/5182/ 存储不想同组和想同组的人员信息:存入数组,数据类型为一对字符串 存储所有的组队信息:存入哈希表,数据类型为“键:字符串”“值:一对字符串” 想要知道最终的分组情况,只需要查询数组中的队员情况与想同组 or 不想同组的成员名字是否一致即可 时间复杂度 O(n)O(n)O(n),空间复杂度 + 图论 【拓扑】有向图的拓扑序列 https://www.acwing.com/problem/content/850/ 题意:输出一个图的拓扑序,不存在则输出-1 思路: 首先我们要知道拓扑图的概念,感官上就是一张图可以从一个方向拓展到全图,用数学语言就是:若一个由图中所有点构成的序列 A 满足:对于图中的每条边 (x,y),x 在 A 中都出现在 y 之前,则称 A 是该图的一个拓扑序列 接
@@ -358,15 +358,15 @@

- - number-theory + + greedy

- +
- 数论 整数问题。 【质数】Divide and Equalize 题意:给定 nnn 个数,问能否找到一个数 numnumnum,使得 numn=∏i=1nainum^n = \prod_{i=1}^{n}a_inumn=∏i=1n​ai​ 原始思路:起初我的思路是二分,我们需要寻找一个数使得n个数相乘为原数组所有元素之积,那么我们预计算出所有数之积,并且在数组最大值和最小值之间进行二分,每次二 + 贪心 大胆猜测,小心求证(不会证也没事,做下一题吧)。证明方法总结了以下几种 反证法:假设取一种方案比贪心方案更好,得出相反的结论 边界法:从边界开始考虑,因为满足边界条件更加容易枚举,从而进行后续的贪心 直觉法:遵循社会法则() 1. green_gold_dog, array and permutation https://codeforces.com/contest/1867/probl
diff --git a/page/26/index.html b/page/26/index.html index 2cfdaf5ec..bd33a5464 100644 --- a/page/26/index.html +++ b/page/26/index.html @@ -358,15 +358,15 @@

- - data-structure + + divide-and-conquer

- +
- 数据结构 数据结构由 数据 和 结构 两部分组成。我们主要讨论的是后者,即结构部分。 按照 逻辑结构 可以将其区分为 线性结构 和 非线性结构。 按照 物理结构 可以将其区分为 连续结构 和 分散结构。 【模板】双链表 https://www.acwing.com/problem/content/829/ 思路:用两个空结点作为起始状态的边界,避免所有边界讨论。 时间复杂度:插入、删除结点均 + 分治 将大问题转化为等价小问题进行求解。 【分治】随机排列 https://www.acwing.com/problem/content/5469/ 题意:给定一个 n 个数的全排列序列,并将其进行一定的对换,问是对换了 3n 次还是 7n+1 次 思路:可以发现对于两种情况,就对应对换次数的奇偶性。当 n 为奇数:3n 为奇数,7n+1 为偶数;当 n 为偶数:3n 为偶数,7n+1 为奇数。
diff --git a/page/27/index.html b/page/27/index.html index cc7681ccb..132f7b3fc 100644 --- a/page/27/index.html +++ b/page/27/index.html @@ -304,15 +304,15 @@

- - dfs-and-similar + + data-structure

- +
- 搜索 无论是深搜还是宽搜,都逃不掉图的思维。我们将搜索图建立起来之后,剩余的编码过程就会跃然纸上。 【dfs】机器人的运动范围 https://www.acwing.com/problem/content/22/ 1234567891011121314151617181920212223242526272829303132333435class Solution {public: int r + 数据结构 数据结构由 数据 和 结构 两部分组成。我们主要讨论的是后者,即结构部分。 按照 逻辑结构 可以将其区分为 线性结构 和 非线性结构。 按照 物理结构 可以将其区分为 连续结构 和 分散结构。 【模板】双链表 https://www.acwing.com/problem/content/829/ 思路:用两个空结点作为起始状态的边界,避免所有边界讨论。 时间复杂度:插入、删除结点均
@@ -358,15 +358,15 @@

- - divide-and-conquer + + dfs-and-similar

- +
- 分治 将大问题转化为等价小问题进行求解。 【分治】随机排列 https://www.acwing.com/problem/content/5469/ 题意:给定一个 n 个数的全排列序列,并将其进行一定的对换,问是对换了 3n 次还是 7n+1 次 思路:可以发现对于两种情况,就对应对换次数的奇偶性。当 n 为奇数:3n 为奇数,7n+1 为偶数;当 n 为偶数:3n 为偶数,7n+1 为奇数。 + 搜索 无论是深搜还是宽搜,都逃不掉图的思维。我们将搜索图建立起来之后,剩余的编码过程就会跃然纸上。 【dfs】机器人的运动范围 https://www.acwing.com/problem/content/22/ 1234567891011121314151617181920212223242526272829303132333435class Solution {public: int r