dustland

dustball in dustland

Crack jar

jar包是啥

jar(java archieve file),java归档文件

与zip比较

其内容和zip文件非常相似,甚至可以直接使用360压缩这种软件解压

唯一的区别就是,jar中有一个META-INF目录,这里面有一个MANIFEST.MF文件,该文件可能长这样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Manifest-Version: 1.0
Implementation-Title: challenge
Implementation-Version: 0.0.1-SNAPSHOT
Built-By: shiyu
Implementation-Vendor-Id: io.tricking
Spring-Boot-Version: 2.1.0.RELEASE
Main-Class: org.springframework.boot.loader.JarLauncher
Start-Class: io.tricking.challenge.ChallengeApplication
Spring-Boot-Classes: BOOT-INF/classes/
Spring-Boot-Lib: BOOT-INF/lib/
Created-By: Apache Maven 3.5.3
Build-Jdk: 1.8.0_102
Implementation-URL: https://projects.spring.io/spring-boot/#/spring-bo
ot-starter-parent/challenge

其中最关键的是Main-Class信息,即jar包的入口点

有些jar包可以通过java -jar a.jar这种命令被执行,此时Main-Class就指定从哪个类的Main函数开始执行

可以理解为,这个META-INF/MANIFEST.MF就是一个清单,表明该jar包的概要信息.

jar包其他文件主就是class字节码文件等

打包目的

打jar包的目的,实际上和c/c++中制作.lib静态库或者.dll动态库一样,就一个文件,方便别人拷贝使用

模块化,标准化

只要写好文档,告诉别人如何调用jar包中的类和函数即可

maven管理的第三方库,都是使用jar包的形式提供的

image-20230126155922111

jar vs war

war包是Sun公司提出的web应用程序格式

两者相同点,都是一个压缩包

不同点,目录结构有区别

凡是能打war包的东西,必然能够打jar包

如何打包

源代码

源代码结构

1
2
3
4
5
6
7
8
9
10
┌──(root㉿Executor)-[/mnt/c/Users/86135/Desktop/empire/empiree]
└─# tree src -f
src
└── src/top
└── src/top/dustball
├── src/top/dustball/Dao
│   └── src/top/dustball/Dao/User.java
└── src/top/dustball/Main.java

3 directories, 2 files

其中User.java是一个pojo

1
2
3
4
5
6
7
8
9
10
11
12
13
package top.dustball.Dao;
public class User {
String username;
String password;

public User(String username,String password){
this.username=username;
this.password=password;
}
public String toString(){
return "["+username+","+password+"]";
}
}

Main.java是程序入口,依赖User类

1
2
3
4
5
6
7
8
package top.dustball;
import top.dustball.Dao.User;
public class Main {
public static void main(String[] args) {
User user = new User("vader", "sjh");
System.out.println(user);
}
}

javac编译

打入jar包的都是class字节码文件,没有java源文件,因此首先需要javac编译

可以使用javac <sourcefile> -d out将所有字节码文件打包到out目录下

这里sourcefile没有找到一个一劳永逸的方法

每个javac命令最多只能编译一个目录下的所有java文件

1
2
3
4
top.dustball/
Dao/
User.java
Main.java

这里Dao/User.java和Main.java在不同的目录下,并且Main.java中依赖User.java,因此需要写一个很长的编译命令

1
2
┌──(root㉿Executor)-[/mnt/c/Users/86135/Desktop/empire/empiree]
└─# javac src/top/dustball/Dao/*.java src/top/dustball/*.java -d out

更好的方法是将所有需要编译的源代码文件写到一份清单中,然后让javac根据清单工作,比如在根目录下写一个JavaList.txt

1
2
src/top/dustball/Dao/User.java
src/top/dustball/Main.java

此时在此处执行javac,就是javac的当前工作目录

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
┌──(root㉿Executor)-[/mnt/c/Users/86135/Desktop/empire/empiree]
└─# javac @JavaList.txt -d out

┌──(root㉿Executor)-[/mnt/c/Users/86135/Desktop/empire/empiree]
└─# ls
bin JavaList.txt lib out README.md src

┌──(root㉿Executor)-[/mnt/c/Users/86135/Desktop/empire/empiree]
└─# tree -f out
out
└── out/top
└── out/top/dustball
├── out/top/dustball/Dao
│   └── out/top/dustball/Dao/User.class
└── out/top/dustball/Main.class

3 directories, 2 files

此时在根目录下已经生成了out目录,保留了包结构和所有class文件,下一步就是打jar包了

jar打包

1
2
3
4
5
6
7
8
9
┌──(root㉿Executor)-[/mnt/c/Users/86135/Desktop/empire/empiree]
└─# jar -cvf Main.jar out/top/dustball/Main.class out/top/dustball/Dao/User.class
added manifest
adding: out/top/dustball/Main.class(in = 523) (out= 341)(deflated 34%)
adding: out/top/dustball/Dao/User.class(in = 897) (out= 485)(deflated 45%)

┌──(root㉿Executor)-[/mnt/c/Users/86135/Desktop/empire/empiree]
└─# ls *.jar
Main.jar

此时在jar工作目录,也就是跟目录下,生成了Main.jar其中都有啥呢?

1
2
3
4
5
6
┌──(root㉿Executor)-[/mnt/c/Users/86135/Desktop/empire/empiree]
└─# jar -tf Main.jar
META-INF/
META-INF/MANIFEST.MF
out/top/dustball/Main.class
out/top/dustball/Dao/User.class

两个class都已经打包进入了Main.jar

关键在这个META-INF/MANIFEST.MF

提取Main.jar之后,该文件长这样

1
2
Manifest-Version: 1.0
Created-By: 11.0.17 (Debian)

唯一的有效信息是jdk版本11.0.17,Debian操作系统

没有Main-Class入口点信息,显然直接java -jar是不可以执行的

1
2
3
┌──(root㉿Executor)-[/mnt/c/Users/86135/Desktop/empire/empiree]
└─# java -jar Main.jar
no main manifest attribute, in Main.jar

没有主属性清单

怎么改呢?

写到这里发现一个重大错误,包名不统一了

啥意思呢?

之前javac,jar的工作目录都是根目录,而包在src路径之下,是根目录的子目录

1
2
3
4
5
6
┌──(root㉿Executor)-[/mnt/c/Users/86135/Desktop/empire/empiree]
└─# jar -tf Main.jar
META-INF/
META-INF/MANIFEST.MF
out/top/dustball/Main.class
out/top/dustball/Dao/User.class

此处out路径被当作了包的最外层,

可能是jar打包认为out也是包路径,但是class自己认为out不是包路径,此后就出错了

而实际上应该让包从top开始,如下:

1
2
3
4
5
6
┌──(root㉿Executor)-[/mnt/c/Users/86135/Desktop/empire/empiree]
└─# jar -tf Main.jar
META-INF/
META-INF/MANIFEST.MF
top/dustball/Main.class
top/dustball/Dao/User.class

按照这样修改之后,在META-INF/MANIFEST.MF中这样写:

1
2
3
4
Manifest-Version: 1.0
Created-By: 11.0.17 (Debian)
Main-Class: top.dustball.Main

这就指定好了入口类

然后重新打包,这一次需要-m指定META-INF/MANIFEST.MF文件,作为jar包清单

1
2
3
4
5
6
┌──(root㉿Executor)-[/mnt/c/Users/86135/Desktop/empire]
└─# jar -cvfm Main.jar META-INF/MANIFEST.MF top/dustball/Main.class top/dustball/Dao
added manifest
adding: top/dustball/Main.class(in = 523) (out= 341)(deflated 34%)
adding: top/dustball/Dao/(in = 0) (out= 0)(stored 0%)
adding: top/dustball/Dao/User.class(in = 897) (out= 485)(deflated 45%)

执行

1
2
3
┌──(root㉿Executor)-[/mnt/c/Users/86135/Desktop/empire]
└─# java -jar Main.jar
[vader,sjh]

更灵活的打包方式

javac编译时,使用清单列出需要编译的源文件是一个好办法

jar打包时有没有更方便的方法呢?

刚才我们有两个打jar包的方式,其一是直接指定所有class文件

1
jar -cvf Main.jar out/top/dustball/Main.class out/top/dustball/Dao/User.class

此时Manifest文件是自动生成的,不包含入口点等信息

需要编写Manifest文件之后重新打包

1
jar -cvfm Main.jar META-INF/MANIFEST.MF top/dustball/Main.class top/dustball/Dao

奇怪的是,当时Dao/User.class没有敲上,敲到Dao就停了,也成功打包了

现在把修改后的META-INF/MANIFEST.MF整体拷贝到out目录下,现在的out目录:

1
2
3
4
5
6
7
8
9
10
11
12
┌──(root㉿Executor)-[/mnt/c/Users/86135/Desktop/empire/empiree/out]
└─# tree -f
.
├── ./META-INF
│   └── ./META-INF/MANIFEST.MF
└── ./top
└── ./top/dustball
├── ./top/dustball/Dao
│   └── ./top/dustball/Dao/User.class
└── ./top/dustball/Main.class

4 directories, 3 files

在此处指定META-INF/MANIFEST.MF并打包

1
2
3
4
5
6
7
8
9
10
11
12
13
14
┌──(root㉿Executor)-[/mnt/c/Users/86135/Desktop/empire/empiree/out]
└─# jar -cvfm Main.jar META-INF/MANIFEST.MF *
added manifest
ignoring entry META-INF/
ignoring entry META-INF/MANIFEST.MF
adding: top/(in = 0) (out= 0)(stored 0%)
adding: top/dustball/(in = 0) (out= 0)(stored 0%)
adding: top/dustball/Dao/(in = 0) (out= 0)(stored 0%)
adding: top/dustball/Dao/User.class(in = 897) (out= 485)(deflated 45%)
adding: top/dustball/Main.class(in = 523) (out= 341)(deflated 34%)

┌──(root㉿Executor)-[/mnt/c/Users/86135/Desktop/empire/empiree/out]
└─# ls
Main.jar META-INF top

执行jar包

1
2
3
┌──(root㉿Executor)-[/mnt/c/Users/86135/Desktop/empire/empiree/out]
└─# java -jar Main.jar
[vader,sjh]

更更灵活的打包方式

在打包的时候不指定入口点而在执行的时候指定入口点

out目录状态:

1
2
3
4
5
6
7
8
9
10
┌──(root㉿Executor)-[/mnt/c/Users/86135/Desktop/empire/empiree/out]
└─# tree -f
.
└── ./top
└── ./top/dustball
├── ./top/dustball/Dao
│   └── ./top/dustball/Dao/User.class
└── ./top/dustball/Main.class

3 directories, 2 files

直接再次处打jar包

1
2
3
4
5
6
7
8
9
┌──(root㉿Executor)-[/mnt/c/Users/86135/Desktop/empire/empiree/out]
└─# jar -cvf Main.jar *
added manifest
adding: top/(in = 0) (out= 0)(stored 0%)
adding: top/dustball/(in = 0) (out= 0)(stored 0%)
adding: top/dustball/Dao/(in = 0) (out= 0)(stored 0%)
adding: top/dustball/Dao/User.class(in = 897) (out= 485)(deflated 45%)
adding: top/dustball/Main.class(in = 523) (out= 341)(deflated 34%)

运行时指定入口点

1
2
3
┌──(root㉿Executor)-[/mnt/c/Users/86135/Desktop/empire/empiree/out]
└─# java -cp Main.jar top.dustball.Main
[vader,sjh]

依赖jar包

现在jar包中有一个top.dustball.User类,在Test类中调用之

在Test路径下有一个Main.jar这个包,还有测试类Test

1
2
3
┌──(root㉿Executor)-[/mnt/c/Users/86135/Desktop/empire/Test]
└─# ls
Main.jar Test.java

其中Test.java试图调用User类

1
2
3
4
5
6
7
8
9
10
11
12
package Test;

import top.dustball.Dao.User;

public class Test {
public static void main(String[] args) {
User user = new User("vader", "sjh");

System.out.println(user);
}
}

编译链接要加上-cp选项,指定依赖项

1
2
--class-path <path>, -classpath <path>, -cp <path>
Specify where to find user class files and annotation processors
1
PS C:\Users\86135\Desktop\empire\Test> javac Test.java -cp Main.jar

由于Test.java有一个包Test,因此需要退到上级目录执行

1
2
3
PS C:\Users\86135\Desktop\empire\Test> cd ..
PS C:\Users\86135\Desktop\empire> java Test/Test
[vader,sjh]

命令

jar命令

jar命令是jdk的工具,该exe文件在jdk/bin目录下,需要将该bin目录添加到系统环境变量path,也就是配置jdk环境变量

jar -tf

列表列出jar包结构

1
jar -tf a.jar
1
2
3
4
5
6
┌──(root㉿Executor)-[/mnt/c/Users/86135/Desktop/empire/empiree]
└─# jar -tf Main.jar
META-INF/
META-INF/MANIFEST.MF
out/top/dustball/Main.class
out/top/dustball/Dao/User.class

jar -xf

提取jar包文件,效果等同于解压

1
2
3
4
5
6
7
8
9
10
11
PS C:\Users\86135\Desktop\javacon> jar -xf challange.jar
PS C:\Users\86135\Desktop\javacon> ls

Directory: C:\Users\86135\Desktop\javacon

Mode LastWriteTime Length Name
---- ------------- ------ ----
d---- 2018/11/6 13:34 BOOT-INF
d---- 2018/11/6 13:34 META-INF
d---- 2018/11/6 13:34 org
-a--- 2023/1/26 15:21 18134425 challange.jar

javac命令

javac java编译器,用于将源代码java文件编译为class字节码文件

javac可以类比为gcc或者g++

javac -d

指定class文件输出位置,保留包结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
┌──(root㉿Executor)-[/mnt/c/Users/86135/Desktop/empire/empiree]
└─# tree src -f
src
└── src/top
└── src/top/dustball
├── src/top/dustball/Dao
│   └── src/top/dustball/Dao/User.java
└── src/top/dustball/Main.java

3 directories, 2 files

┌──(root㉿Executor)-[/mnt/c/Users/86135/Desktop/empire/empiree]
└─# javac src/top/dustball/Dao/*.java src/top/dustball/*.java -d out

┌──(root㉿Executor)-[/mnt/c/Users/86135/Desktop/empire/empiree]
└─# ls
bin lib out README.md src

┌──(root㉿Executor)-[/mnt/c/Users/86135/Desktop/empire/empiree]
└─# tree out -f
out
└── out/top
└── out/top/dustball
├── out/top/dustball/Dao
│   └── out/top/dustball/Dao/User.class
└── out/top/dustball/Main.class

3 directories, 2 files

javac -cp

链接外部依赖

1
PS C:\Users\86135\Desktop\empire\Test> javac Test.java -cp Main.jar

java命令

java -jar

执行自带入口点的jar包

java -cp

运行时指定jar包入口点

1
2
3
┌──(root㉿Executor)-[/mnt/c/Users/86135/Desktop/empire/empiree/out]
└─# java -cp Main.jar top.dustball.Main
[vader,sjh]

Class字节码文件

javac编译.java源文件之后生成的 class字节码文件

class文件其中都包含了什么?

java是如何编译链接的?

jar包和class的关系?

class文件能否理解为c语言编译生成的可重定位目标模块.o?

每个interface,每个类,都会编译生成一个class文件.即使两个类写到同一个java文件里了,编译后也会生成两个class文件

Point.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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
package com.dustball;

import java.lang.Math;

interface Measurable {
public double getDistance(Point p);
}

public class Point implements Measurable {

protected double __x;
protected double __y;

public Point(double _x, double _y) {
__x = _x;
__y = _y;
}

public String toString() {
return "(" + Double.toString(__x) + "," + Double.toString(__y) + ")";
}

public double getDistance(Point p) {
double dist = 0;
dist = (__x - p.__x) * (__x - p.__x) + (__y - p.__y) * (__y - p.__y);
return Math.sqrt(dist);
}
public static void main(String[] args) {
Point A=new Point(2,5);
System.out.println(A.toString());
}
}

class TaggedPoint extends Point {

String __t;

TaggedPoint(String _t, double _x, double _y) {
super(_x, _y);
__t = _t;
}
}

final class Line {

public Point __A;
public Point __B;

Line(Point _A, Point _B) {
__A = _A;
__B = _B;
}
}

这里面有一个接口Measurable,一个Point类及其子类TaggedPoint,一个final类Line

编译后生成了四个文件

1
2
3
4
5
6
7
8
9
10
11
12
PS C:\Users\86135\Desktop\java\cmd\target\classes\com\dustball> ls

Directory: C:\Users\86135\Desktop\java\cmd\target\classes\com\dustball

Mode LastWriteTime Length Name
---- ------------- ------ ----
-a--- 2022/12/30 20:02 467 Line.class
-a--- 2022/12/30 20:02 191 Measurable.class
-a--- 2022/12/30 20:02 1004 Point.class
-a--- 2022/12/30 20:02 456 TaggedPoint.class

PS C:\Users\86135\Desktop\java\cmd\target\classes\com\dustball> 010editor *.class

直接010editor *.class全部打开观察

class文件结构

整个class文件大体上线性地排列者以下几部分

image-20221230194129578

magic

文件魔术0xCAFEBABE

minor_version/major_version

副/主 版本号

描述本class文件可以被java几执行

image-20221230173614375

但是这个52是怎么来的?主版本号何曾到过52?

这是因为java的最初版本号是45,不是从0开始的

52是jdk1.8?然而我也不知道怎么换算的,可以从010editor模板代码中看出这一点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
string ClassFileOnComment(ClassFile &obj)
{
switch(obj.major_version)
{
case 45:
return "JDK 1.0 or JDK 1.1";
break;
case 46:
return "JDK 1.2";
break;
case 47:
return "JDK 1.3";
break;
case 48:
return "JDK 1.4";
break;
case 49:
return "JDK 1.5";
break;
case 50:
return "JDK 1.6";
break;
case 51:
return "JDK 1.7";
break;
case 52:
return "JDK 1.8";
break;
case 53:
return "JDK 1.9";
break;
default:
return "JDK ?.?";
break;
}
}

constant_pool_count

常量池容量计数值,该值决定了紧接着的constant_pool[]数组的元素个数

constant_pool[]常量池

可以理解为可重定位目标模块中的符号表.symtab

这个数组的0下标位置是空出来不使用的,从1下标开始使用

0下标可以作为"空引用",也就是说其他元素引用0号元素意味着引用个寂寞

奇怪的是,这个数组的每个元素可以不一样大

正常c语言的结构体数组,每个元素都是相同大小的结构体,即使有些域用不到也得空着占位

而对于constant_pool:

每个元素最开始必然有一个tag成员,表明该元素是何种类型,也就确定了其占地大小,

常量池中主要存放两大类常量:字面量Literal和符号引用Symbolic References

access_flags

访问标志

两个字节表示的访问标志,用于描述本类的信息,多个属性按位或

image-20221230195212243
类型 类名 继承 access_flag
interface Measurable Object 0x0600(抽象|接口)
public class Point Object 0x0021(公共|子类)
class TaggedPoint Point 0x0020(子类)
final class Line Object 0x0030(final|子类)

索引集合

紧跟在访问标志之后,是

本类索引,

父类索引,

接口索引集合

image-20221230202234391

只有接口是一个集合,是因为java中只允许单继承,父类只能有一个,但是接口可以实现多个

索引值是一个下标,比如this_class=1,意思是去常量池中找1号常量

image-20221230202532520

tag=1表示utf8_info,即一个字符串

正好就是本类类名

又如super_class=3,意思是去常量池找3号常量

image-20221230202946050

也是一个tag=1,utf8字符串,正好是Object类名

this_class和super_class之后是interfaces_count,一个整数表示本类实现了几个接口,有一个算一个都要列在后面的interfaces索引数组中

image-20221230203155953

Point类只实现了一个Measurable接口他的索引号是5

image-20221230203241579

字段表集合

用于描述接口或者类中声明的变量

首先是一个整数fields_count描述类有几个字段

紧跟着就是对每个字段的描述

image-20221230205132151

这些描述包括:

access_flags,访问标志,包括static,public,final,volatile等等

name_index,符号名自己在常量池中的下标,也就是__x的下标

descriptor_index,类型名在常量池中的下标,也就是double的下标

attributes_count

access_flags

每一种属性占用一位,该位为0表示没有这种属性,为1表示有

共有16位,只用到9位

image-20221230205606249

对于double __x;,其属性是0x0004(protected)

image-20221230210219778

name_index

字段的简单名称

"简单名称"意思是没有加上类名前缀,

__x的完整名称应该是com/dustball/Point.__x

但是当前文件就是在描述Point类,显然没有必要再给其成员存放完整名称了

descriptor_index

描述符在符号表中的下标

描述符就是double,int等等的类型

只需要一个字符D就可以表明double类型

image-20221230211059690

额外属性

额外属性可能有多个,因此首先一个attributes_count整数,表明有多少个额外属性

有多少个就在后面列明多少个

这里__x没有额外属性

方法表集合

image-20221230211542677

方法表和字段表的结构比较相似

首先还是访问修饰

image-20221230211704703

然后是简单名称引用和描述符引用

这两个合起来就正这样

image-20221230212018387

<init>(DD)V这里<init>是构造函数名,(DD)表明两个double类型的参数,V表明无返回值

main([Ljava/lang/String;)V,这里main是主函数名([Ljava/lang/String;)注意这里中括号没有对齐,只有左中括号表明参数是一个数组类型

然后是attributes_count属性个数和attributes属性表

字段,方法都可以有属性表

方法编译成的opcode就放在其属性表中

属性表

Code属性

image-20221230212736701
image-20221230212754406
attribute_name_index=Code

表明这是Code属性

attribute_length

本属性的长度

max_stack

操作数栈深度最大值

max_locals

局部变量最大存储空间(单位:槽)

32位及以下数据用一个槽

64位及以上用两个槽

槽可以复用,函数中用完死了的变量给后面的新局部变量腾空

max_stack和max_locals两者共同决定了函数栈帧的大小

code_length

指令长度

code

指令码,可以类比作x86机器码

只不过这个指令是给JVM看的,x86机器码是给x86机器看的

image-20221230213315763

用javap 反编译也可以看到

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public com.dustball.Point(double, double);
descriptor: (DD)V
flags: (0x0001) ACC_PUBLIC
Code:
stack=3, locals=5, args_size=3
0: aload_0
1: invokespecial #13 // Method java/lang/Object."<init>":()V
4: aload_0
5: dload_1
6: putfield #16 // Field __x:D
9: aload_0
10: dload_3
11: putfield #18 // Field __y:D
14: return
LineNumberTable:
line 14: 0
line 15: 4
line 16: 9
line 17: 14
LocalVariableTable:
Start Length Slot Name Signature
0 15 0 this Lcom/dustball/Point;
0 15 1 _x D
0 15 3 _y D
MethodParameters:
Name Flags
_x
_y

至于java自己造的给虚拟机看的那些指令,现在不想研究

关于jvm多重要多重要,感觉都是大量java码农吹出来的.不如看先看明白CSAPP

java编译链接

同一包下有两个文件,存在依赖关系

App.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package top.dustball;
import top.dustball.Point;

public class App
{

public static void main( String[] args )
{

Point p=new Point(2,5);
System.out.println(p.toString());
}
}

Point.java

1
2
3
4
5
6
7
8
9
10
11
12
package top.dustball;
public class Point {
double x;
double y;
public Point(double x,double y){
this.x=x;
this.y=y;
}
public String toString(){
return "("+x+","+y+")";
}
}

其中App.java中,Main函数引用Point构造函数和toString函数

如果只编译App.java会报告找不到符号出错误

需要同时编译App.java和Point.java

1
javac App.java Point.java 

在同一目录下生成Point.class和App.class文件

如果要用java执行App.class文件,需要推到包路径下java top/dustball/App

1
2
3
4
5
6
7
PS C:\Users\86135\Desktop\vsJava\empire\src\main\java\top\dustball> javac App.java Point.java 
PS C:\Users\86135\Desktop\vsJava\empire\src\main\java\top\dustball> java App
错误: 找不到或无法加载主类 App
原因: java.lang.NoClassDefFoundError: top/dustball/App (wrong name: App)
PS C:\Users\86135\Desktop\vsJava\empire\src\main\java\top\dustball> cd ../../
PS C:\Users\86135\Desktop\vsJava\empire\src\main\java> java top/dustball/App
(2.0,5.0)

这两个class文件都有用,如果把Point.class搬走,只执行App.class会报告链接错误

1
2
3
4
5
PS C:\Users\86135\Desktop\vsJava\empire\src\main\java> java top/dustball/App
(2.0,5.0)
PS C:\Users\86135\Desktop\vsJava\empire\src\main\java> java top/dustball/App
错误: 加载主类 top.dustball.App 时出现 LinkageError
java.lang.ClassFormatError: Incompatible magic value 1347093252 in class file top/dustball/App

这个Point.class的位置在导入类的时候就决定了

1
2
package top.dustball;
import top.dustball.Point;

也就是说必须得和App同一路径下

jar包

java archieve

文件魔数是504B0304,和zip压缩包是相同的

image-20230104214655752

解压缩或者jar -xf 命令均可以拆jar包

1
2
3
4
5
6
7
8
9
10
11
12
PS C:\Users\86135\Desktop\jar> jar -xf junit-4.13.2.jar
PS C:\Users\86135\Desktop\jar> ls

Directory: C:\Users\86135\Desktop\jar

Mode LastWriteTime Length Name
---- ------------- ------ ----
d---- 2021/2/13 17:31 junit
d---- 2021/2/13 17:31 META-INF
d---- 2021/2/13 17:31 org
-a--- 2023/1/3 21:30 384581 junit-4.13.2.jar
-a--- 2021/2/13 17:31 11376 LICENSE-junit.txt

解出来/org/junit和/junit两个目录下面都是class文件

因此实际上jar包可以理解为静态库,里面的class文件可以理解为可重定位目标模块

麻了麻了怎么用jar包,jar包如何自己运行,用到时候再看吧,全是答辩

数位DP

P2602 数字计数

给定正整数a,b,求\([a,b]\)所有的整数中,0到9每个数码各自总共出现多少次

由前缀和的思想,设\(sum[n]\)表示\([0,n]\)中0到9各个数码各自出现了多少次

那么\(sum[b]-sum[a-1]\)就是所求值

问题转化为如何求\(sum[n]\)\([0,n]\)中0到9各个数码各自出现了多少次

设计状态\(dp[i]\)表示"当范围涵盖了完整的i位时,前i位中每个数字出现的次数."

啥意思呢?

比如对于\([1000,1999]\)显然能够找出i=1的完整区间,比如\([1000,1009]\)等等

显然也能够找出i=2的完整区间,比如\([1900,1999]\)等等

显然也能够找出i=3的完整区间,比如\([1000,1999]\),只有这一个

找不出i=4的完整区间了

如果对于[10,2000000],这就可以找到i=4的完整区间,也就是低四位能够遍历[0,9999]这个范围的区间,比如

\([10000,19999]\)

这样设计dp有一个隐患就是,忽略了前导0的区别.比如给定区间[0,999],显然最大可以找到i=3的区间[0,999]

但是正常情况下,0不能作为前导,也就是说不能有012或001或000或020这种数字,可以有210或100这种数字

最后再考虑去除前导零的情况,现在先不考虑

状态转移方程为: \[ dp[i]=10\times dp[i-1]+10^{i-1} \] 如何考虑这个方程呢?排列组合问题

第i位假设放了一个X(先不管到底放了啥),考虑他对低i-1位的影响,

X有0,1,2,...,9十种可能,于是之前计算得到的dp[i-1]就得乘以10,

也就是说,1在低i-1上出现过\(10\times dp[i-1]\)

okk,现在看低i-1位给第i位造成的影响,第i位假如放一个1,低i-1位随便放有\(10^{i-1}\)种放法

因此1就在第i位上出现过\(10^{i-1}\)

举个实际例子,以[0,999]为例,前导零的情况也计算在内

\(dp[1]=1\),边界条件,意思是区间为[0,9]的时候,一个数字显然只出现一次

\(dp[2]=10\times dp[1]+10^{1}=20\)

假设个位上放一个X,十位有十种可能,0X,1X,2X,3X,...,9X,也就是说,十位对个位计数的贡献就是\(10\times dp[1]\)

假设十位上放一个X,个位有十种可能,X0,X1,X2,X3,...,X9.也就是说,个位对十位计数的贡献就是\(10^{1}\)

笑死,只有两位,要不你影响我,要不我影响你,怎么贡献方式还不一样?

再多一位就可以体现了

\(dp[3]=10\times dp[2]+10^{2}=300\)

这时候发现貌似不容易计算百位对于前两位的贡献了,如何解释\(10\times dp[2]\)怎么来的呢?

可以这样想:

原来在计算\(dp[2]\)时,这样只有一个[0,99]区间,而考虑了百位之后,就有

\([0,99],[100,199],[200,299],...,[900,999]\)这10个区间了,每个区间内数字的出现个数都是\(dp[2]\)现在有十个相同区间,因此当有三位的时候,从前两位计算得到的数字出现个数就是\(10\times dp[2]\)

假设百位上放一个X,前两位稍微一动就是一个新数,X就得多记录一次

比如X23,X24,X25,这样百位上的X就记了3次.那么前两位一共有多少种可能的排列呢?\(10^2\)那么X在第三位上出现的次数就是\(10^2\)

然后考虑如何去除前导0

去除前导零,也就是给0的计数中去掉0出现在最高位的情况

去除前导0,只会对0的计数有影响,对其他数字没有影响,因为

012345去除前导0不是说把012345给去掉,而是保留12345,12345这五个数字各自统计一次,0此时不应计数

要去除i=6上的前导零计数,也就是去除

0XXXXX这种情况对0计数的贡献,显然有\(10^5\)

去除i=5上的前导零计数,也就是去除

0XXXX这种情况对0计数的贡献,显然有\(10^4\)

也就是去除第i位上的前导0影响,就是去除\(10^{i-1}\)次0的计数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#include <iostream>
#include <algorithm>
#include <vector>
using namespace std;
const int maxn=15;
long long dp[maxn];
long long pow_10[maxn];

vector<long long> solve(long long n){
long long temp=n;
int length=0;
int top[maxn];
vector<long long> result(10);
while(n){
top[++length]=n%10;
n/=10;
}
for(int i=length;i>=1;--i){
for(int j=0;j<10;++j){
result[j]+=dp[i-1]*top[i];
}
for(int j=0;j<top[i];++j){
result[j]+=pow_10[i-1];
}
temp-=pow_10[i-1]*top[i];
result[top[i]]+=temp+1;
result[0]-=pow_10[i-1];
}
return result;
}
long long l,r;
int main(){
cin>>l>>r;
pow_10[0]=1;
for(int i=1;i<maxn;++i){
dp[i]=dp[i-1]*10+pow_10[i-1];
pow_10[i]=10*pow_10[i-1];
}
vector<long long>left=solve(l-1);
vector<long long>right=solve(r);
for(int i=0;i<=9;++i){
cout<<right[i]-left[i]<<" ";
}
return 0;
}

其中比较抽象的是后面这个循环,他里面都干了什么呢?

1
for(auto j=0;j<10;++j)result[j]+=dp[i-1]*top[i];

计算当前位i随便安排时,对前i位中,数位统计的贡献

使用\(top[i]\)因为第i位上只有\([0,top[i]-1]\)\(top[i]\)种情况

为什么不是\([0,top[i]]\)\(top[i]+1\)种情况?假如对于\([0,6524]\)

当前i=4,top[4]=6,可以找到区间[0,999],[1000,1999]

[2000,2999],[3000,3999],[4000,4999],[5000,5999]

共6个=top[4]

没有包括[6000,6999],因为最大到6524,即top[i]作为最高位时前i位找不出完整的区间来

1
for(auto j=0;j<top[i];++j)result[j]+=pow(10,i-1); 

计算前i位随便安排时,对第i位数位统计的贡献

由于最高位上只可能出现\([0,top[i]-1]\),再高top[i]没有完全区间,再高\(top[i]+1\)不可能出现,因此不能统计对\([top[i],9]\)这些数字的贡献

比如当n=6524

i=6,也就是说存在1XXX,2XXX,3XXX,4XXX,5XXX这些完整区间(XXX表示[0,999])

6XXX没有完全区间,只有[6000,6524]

更不存在7XXX了

1
2
3
       
temp-=pow(10,i-1)*top[i];//去掉最高位
result[top[i]]+=temp+1;//之前统计贡献时,i位的最高数字总是不满区间,现在单独处理

啥意思呢?还是n=6524举例子

temp=n=6524

然后temp=6524-6000=524

这524就是对top[4]=6统计次数的贡献,也就是6在i=4位上出现了524+1=525次

加一的原因是,524表示[0,524]共525种情况

1
result[0]-=pow(10,i-1);//去除前导零

前导零有多种情况,比如0524,0024,0004,0000

这里每次只会处理一种情况,i=4时只会消除0999,0998,...,0000这种前导0.

P8764 二进位问题

\([1,n]\)这些整数中,二进制表示有k个1的有多少个

如果问题变成:m个二进制位,其中有k个1的有多少个数

显然是\(C_m^k\)

假设n表示成二进制形式之后,最高位是\(bit[len]=1\),最低位是\(bit[1]\)

显然前\(len-1\)位所有状态都包含在内了,因此首先有\(C_{len-1}^k\),也就是最高位位0时,低\(len-1\)位任意安排

下面考虑最高位为1的情况,

从次高位\(bit[len-1]\)往低位bit[1]方向看

如果\(bit[i]\)为0则跳过,否则加上\(C_{i-1}^{k-t}\)

其中t表示\([i+1,len]\)这些位上的1个数

比如\(n=101000,len=6\)

i bit[i] 操作
5 0 跳过
4 1 \(+C_{4-1}^{k-1}\)

为啥加上这个数呢?第4位上有一个1,这表明存在

\([100000b,100111b]\),低三位存在完整区间,从这个完整区间中统计有\(k-1\)个1的数,是因为之前已经有一个1了,需要在剩下的位中找出k-1个1即可

现在的主要问题是如何快速求解\(C_m^k\)

\(C_m^0=1\)

\(C_m^k=C_{m-1}^{k-1}+C_{m-1}^k\)

\(c[m][k]\)数组预处理即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
#include <iostream>
#include <algorithm>
#include <vector>
using namespace std;
//c[m][k]
const int maxm=100;
const int maxk=100;
long long c[maxm][maxk];


void init(){
// c[1][0]=c[1][1]=1;
c[0][0]=1;
for(int i=1;i<maxm;i++){
c[i][0]=1;
for(int j=1;j<i;++j){
c[i][j]=c[i-1][j-1]+c[i-1][j];
}
c[i][i]=1;
}
}
void print(){
for(int i=1;i<20;i++){
for(int j=0;j<=i;j++){
cout<<c[i][j]<<" ";
}
cout<<endl;
}
}

long long n;
int k;
long long bit[maxm];
long long len;

int main(){
init();
cin>>n>>k;
long long temp=n;
while(temp){
bit[++len]=temp&1;
temp>>=1;
}
long long result=0;
result+=c[len-1][k];
int t=1;
for(int i=len-1;i>=1;--i){
if(bit[i]==0){
continue;
}
if(k-t<0)break;
result+=c[i-1][k-t];
++t;

}
cout<<result<<endl;

return 0;
}

P2657 windy数

定义windy数:相邻两个十进制位必须至少差2,比如135,2597等等

给定区间[l,r]求这之间的windy数有多少

问题转化为[1,n]中的windy数有多少

设计状态: \[ dp[i][j]表示i个10进制位,最高位第i位上是j的windy数的个数 \] 边界条件显然是只有一位的时候,\(dp[1][j]=1\)

转移方程: \[ dp[i][j]=\sum_{|k-j|\ge2}dp[i-1][k] \]

下面考虑[1,n]中有多少windy数

首先划分成十进位

dec[len],dec[len-1],...,dec[1],最高位是dec[len]

那么显然有低len-1位的完整区间,全算上

第len位上,从1到dec[len]-1这些dp[len][i]也是都有的

只剩下len位上放dec[len]的情况了,这是最贴近上边界的情况,如何考虑?

i从len-1往1遍历

对于当前位i,尝试放置\(0\le j<dec[i]\),因为第i位最高可以为dec[i],比他小的数都存在

然后判断j是否合法,依据就是j和dec[i+1]距离是否大于等于2,

如果是,则加上\(dp[i][j]\)

这样第i位就统计完毕,尝试往更低位统计,第i位最终放置的是dec[i]

如果dec[i+1]和dec[i]相差小于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
#include <iostream>
#include <algorithm>
#include <cmath>
#include <cstring>
using namespace std;
const int maxn=20;
long long dp[maxn][10];
void init(){
for(int j=0;j<10;++j){
dp[1][j]=1;
}
for(int i=2;i<maxn;i++){
for(int j=0;j<10;++j){
for(int k=0;k<10;k++){
if(abs(j-k)>=2){
dp[i][j]+=dp[i-1][k];
}
}
}
}
}
long long solve(int n){
if(n==0){
return 0;
}
long long result=0;
long long temp=n;
long long dec[maxn];
memset(dec,0,sizeof(dec));
int len=0;
while(temp){
dec[++len]=temp%10;
temp/=10;
}
// cout<<"actived"<<endl;
for(int i=1;i<=len-1;++i){
for(int j=1;j<=9;++j){
result+=dp[i][j];
}
}
for(int j=1;j<dec[len];++j){
result+=dp[len][j];
}
for(int i=len-1;i>=1;--i){
// cout<<"in loop"<<endl;
for(int j=0;j<=dec[i]-1;++j){
if(abs(j-dec[i+1])>=2){
result+=dp[i][j];
}
}
if(abs(dec[i]-dec[i+1])<2){
break;
}
}



return result;
}


int l,r;

int main(){
cin>>l>>r;
init();
// cout<<solve(r+1)<<endl<<solve(l)<<endl;
cout<<solve(r)-solve(l-1)<<endl;


return 0;
}

P4999 烦人的数学作业

给定区间[l,r],求每个数各个十进制位的数字和

比如[123,125],就有1+2+3+1+2+4+1+2+5

首先问题转化为[1,n]区间上拆分数字和P1836 数页码

然后问题还可以转化为P2602求每个数字出现的次数

最终累计i×i的出现次数即可

后端MVC框架

参考SMBMS: 跟着狂神做超市订单管理系统 (gitee.com)

MVC框架的使用时机

当需求分析结束后,数据库也已经建立完毕了.建立了四张数据表

1
2
3
4
5
6
7
8
9
10
11
12
mysql> use smbms;
Database changed
mysql> show tables;
+-----------------+
| Tables_in_smbms |
+-----------------+
| smbms_bill |
| smbms_provider |
| smbms_role |
| smbms_user |
+-----------------+
4 rows in set (0.00 sec)

下面的任务就是写后端逻辑代码了,此时使用MVC框架建立项目

project1

MVC框架是什么

整个后端在MVC框架中分了三层,DAO,Service,Controller

DAO层:data access object,数据访问层.每一个DAO类一定对应一个数据表,DAO类的函数,只做原子操作,CRUD

一个函数只实现一个功能,比如给定一个账单号,查库删除该账单

或者给定一个用户名和新密码,修改其密码.

可以理解为,DAO类的函数,一般只执行一条sql语句,一般只影响一行

Service层:服务层.对Dao层的包装,一个Service层函数可能调用一个或者多个Dao层函数.返回pojo对象.

比如service上一个login(userCode,userPassword),首先会用userCode去查smbms_user表,然后用获取到的表项实例化一个user pojo,然后user.getPassword和userPassword进行对比,相同则返回user对象,否则返回null指针

Controller层:控制层,由Servlet实现,直接接收来自前端的get或者post请求,提取参数,决定使用哪种服务

输入 输出 任务
Controller request with parameters respond 提取参数,决定服务
Service raw parameters pojo or else 执行事务,组合业务
DAO connection,raw parameters pojo or else 执行原子操作,CRUD

画个图表示一下

image-20230104191129638

MVC框架如何作用

以登录逻辑为例,分析从前端请求到获得响应的整个过程

image-20230104184242886

1.前端发起HTTP请求

请求服务端你的login.do资源,携带两个get参数

目标DNS:localhost,会被本地hosts文件映射到IP:127.0.0.1

目标端口:8080

2.该请求到达了服务端

经过TCP/IP协议栈,将HTTP报文交给位于应用层的web服务器(tomcat)

3.服务器首先查询web.xml

找到login.do这路径应该映射给控制层的LoginServlet

1
2
3
4
5
6
7
8
<servlet>
<servlet-name>LoginServlet</servlet-name>
<servlet-class>com.threepure.servlet.user.LoginServlet</servlet-class>
</servlet>
<servlet-mapping>
<servlet-name>LoginServlet</servlet-name>
<url-pattern>/login.do</url-pattern>
</servlet-mapping>

至于tomcat具体怎么将HTTP请求报文交给LoginServlet的,目前不清楚,留作后话

可能手写一个玩具tomcat服务器就知道了,但不是现在

于是将该get请求交给LoginServlet.doGet(req,resp)

4.调用控制层LoginServlet.doGet函数

首先提取参数

1
2
String userCode = req.getParameter("userCode");
String userPassword = req.getParameter("userPassword");

然后调用服务层,查询用户名密码是否正确

1
2
UserServiceImpl userService = new UserServiceImpl();
User user = userService.login(userCode, userPassword);

这里user是一个pojo类型,其属性和user数据表的属性一一对应,一个user对象对应一条user表的记录

然后根据user是否为空,决定是否登陆成功

1
2
3
4
5
6
7
if (user!=null){//user非空说明存在该用户且用户名密码正确
req.getSession().setAttribute(Constants.USER_SESSION, user);//设置session
resp.sendRedirect("jsp/frame.jsp");//跳转登录之后的界面
}else {//要么不存在用户名,要么密码错误,反正登不上
req.setAttribute("error", "用户名或者密码错误");
req.getRequestDispatcher("login.jsp").forward(req, resp);//重新登录
}

结果有两个,

一个是设置session,保存用户登录状态,也就是设置了访问权限,此后过滤器会根据是否有session决定能否访问网站资源

一个是返回resp,设置页面重定向.实际上是返回了重定向的HTTP respond报文

5.调用服务层userService.login(userCode, userPassword)函数

首先提升作用域,建立两个局部变量

1
2
Connection connection = null;
User user = null;

connection的建立和销毁都由本login函数决定,也就是资源的创建和销毁是Service服务层决定的

然后调用Dao层函数,根据userDao.getLoginUser(connection, userCode)查询是否存在用户

1
2
3
4
5
6
7
8
try {
connection = DruidDao.getConnection();
user = userDao.getLoginUser(connection, userCode);
} catch (SQLException throwables) {
throwables.printStackTrace();
} finally {
DruidDao.closeResource(null, null, connection);
}

查完了立刻在finally中释放资源

如果存在该用户,Dao层会返回一个user pojo,和数据库中该用户的记录对应.

然后要判断用户密码是否正确,如果正确返回该pojo,控制层会使用该pojo执行其他业务.

如果密码错误或者user不存在,均会返回null,控制层发现该返回值为null就知道了用户名密码错误

1
2
3
4
5
6
if (user != null) {
if (!user.getUserPassword().equals(password)) {
user = null;
}
}
return user;

6.调用DAO层userDao.getLoginUser(connection, userCode)函数

首先提升作用域,创建局部变量

1
2
3
PreparedStatement pstm = null;
ResultSet rs = null;
User user = null;

然后调用DAO公共底层DruidDao.execute函数,查库

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
       if (connection != null) {
String sql = "select * from smbms_user where userCode=?";
Object[] params = {userCode};
try {
rs = DruidDao.execute(connection, pstm, rs, sql, params);//rs可能有多条
if (rs.next()) {//最终user是rs的最后一条记录
user = new User();
user.setId(rs.getInt("id"));//使用rs记录实例化一个pojo
user.setUserCode(rs.getString("userCode"));
user.setUserName(rs.getString("userName"));
user.setUserPassword(rs.getString("userPassword"));
user.setGender(rs.getInt("gender"));
user.setBirthday(rs.getDate("birthday"));
user.setPhone(rs.getString("phone"));
user.setAddress(rs.getString("address"));
user.setUserRole(rs.getInt("userRole"));
user.setCreatedBy(rs.getInt("createdBy"));
user.setCreationDate(rs.getTimestamp("creationDate"));
user.setModifyBy(rs.getInt("modifyBy"));
user.setModifyDate(rs.getTimestamp("modifyDate"));
}
DruidDao.closeResource(rs, pstm, connection);//释放资源
} catch (SQLException throwables) {
throwables.printStackTrace();
}
}
return user;

7.调用DAO层公共函数Druid.execute

CRUD根据读写性质分成两种,只读的查,和读写的增删改

并且只有查询要返回记录,其他的操作只需要返回几行受到影响即可

因此execute有两个重载,一个负责只读的查,一个负责读写的增删改

这里使用sql预编译,目的是提高速度

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/**
* @description:编写查询公共方法
*/
public static ResultSet execute(Connection connection, PreparedStatement preparedStatement, ResultSet resultSet, String sql, Object[] params) throws SQLException {
preparedStatement = connection.prepareStatement(sql);
for (int i = 0; i < params.length; i++) {
//setObject()占位符是从1开始,而数组是从0开始;
preparedStatement.setObject(i + 1, params[i]);
}
System.out.println("DruidDao>>查>>SQL :" + preparedStatement.toString());
resultSet = preparedStatement.executeQuery();
return resultSet;
}

/**
* @description:编写增删改公共方法
*/
public static int execute(Connection connection, PreparedStatement preparedStatement, String sql, Object[] params) throws SQLException {
preparedStatement = connection.prepareStatement(sql);
for (int i = 0; i < params.length; i++) {
//setObject()占位符是从1开始,而数组是从0开始;
preparedStatement.setObject(i + 1, params[i]);
}
System.out.println("DruidDao>>增删改>>SQL :" + preparedStatement.toString());
return preparedStatement.executeUpdate();
}

MVC框架的建立过程

参考SMBMS: 跟着狂神做超市订单管理系统 (gitee.com)

准备工作

0.搭建服务器环境,使用tomcat服务器部署javaweb项目

1.建立pojo目录,在该目录下,给每个数据表建立一个一一对一个的pojo类

1
2
3
4
5
smbms/src/main/java/top/dustball/pojo/
User.java
Provider.java
Bill.java
Role.java

2.建立Dao层公共底层类,实现execute函数

1
2
smbms/src/main/java/top/dustball/dao/
DaoBase.java

3.引入资源,包括前端的jsp页面,js,css文件.数据库连接设置

1
2
smbms/src/main/java/resources/
db.properties
1
2
3
4
5
6
7
8
9
10
11
12
smbms/src/main/webapp/
jsp/
...
js/
...
css/
...
images/
...
login.jsp
error.jsp
...

其中smbms/src/main/webapp/这下面的jsp,js,css,images等都是前端的工作.

前端和后端的接口是jsp和web.xml共同决定的,请求路径以及get或者post的参数名称

1
2
login.jsp
<form class="loginForm" action="${pageContext.request.contextPath }/login.do" name="actionForm" id="actionForm" method="post" >
1
2
3
4
5
6
7
8
9
web.xml
<servlet>
<servlet-name>LoginServlet</servlet-name>
<servlet-class>com.threepure.servlet.user.LoginServlet</servlet-class>
</servlet>
<servlet-mapping>
<servlet-name>LoginServlet</servlet-name>
<url-pattern>/login.do</url-pattern>
</servlet-mapping>

至于前端的页面长什么样,后端不用管,后端只关心控制层接收到的get参数叫什么,是叫"username",还是"UserName"还是"userCode".

前端需要知道一个get请求应该发往哪一个Servlet

只有这两个是前后端开发者需要协商的

4.建立字符过滤器,设置前后端都使用utf-8编码

自顶向下还是自底向上

目前感觉两种方法均可

两头都很具体,顶上是前端写好的请求,参数名都已知,交给哪一个Servlet也是确定的

底下有几个数据表,都长什么样也是确定的

中间的service层很多情况下只是对Dao层的一个薄封装,以service层的UserServiceImpl.login函数为例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public User login(String userCode, String password) {
Connection connection = null;
User user = null;

try {
connection = DruidDao.getConnection();
user = userDao.getLoginUser(connection, userCode);
} catch (SQLException throwables) {
throwables.printStackTrace();
} finally {
DruidDao.closeResource(null, null, connection);
}
//进行密码匹配
if (user != null) {
if (!user.getUserPassword().equals(password)) {
user = null;
}
}
return user;
}

实际上这么一长段的核心功能就是

接收一个userCode一个password,返回一个user pojo.就是多加了一个密码判断

UserServiceImpl.updataPwd更明显,直接调用了Dao层,除此之外啥也没干

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public boolean updatePwd(int id, String pwd) {
Connection connection = null;
boolean flag = false;

try {
connection = DruidDao.getConnection();
if (userDao.updatePwd(connection, id, pwd) > 0) {
flag = true;
}
} catch (SQLException throwables) {
throwables.printStackTrace();
} finally {
DruidDao.closeResource(null, null, connection);
}
return flag;
}

还是采用自顶向下吧,根据前端存在的需求,决定后端写哪些服务

比如对于用户表,不存在修改用户名的需求,因为有新建用户就够了.

如果自底向上写的话,不知道需要对用户表的哪些字段进行什么操作.

如果自顶向下写的话,首先知道的就是需求,然后根据需求决定下层要提供什么服务,然后再实现该服务,不需要的服务不写

建立Controller层

前端和后端商量好的,前端可能会产生哪些请求,每个请求发给哪个servlet

再细分一下,感觉

1
2
3
4
5
这是前端写的
<servlet-mapping>
<servlet-name>LoginServlet</servlet-name>
<url-pattern>/login.do</url-pattern>
</servlet-mapping>
1
2
3
4
5
6
这是后端写的
<servlet>
<servlet-name>LoginServlet</servlet-name>
<servlet-class>com.threepure.servlet.user.LoginServlet</servlet-class>
</servlet>

现在就面临一个问题

用户的行为可能有很多,比如登录,注销,修改密码,订单管理,供应商管理等等

这么多行为,应该用几个servlet实现?

针对用户的行为,有三个Servlet,LoginServlet,LogoutServlet,UserServlet

为啥要这样划分?

首先考虑权限问题,前端是无法过滤请求的,权限控制只能是后端的权限过滤器来做

权限过滤器根据session是否设置决定能否请求资源,对任何/jsp/*的请求生效

用户的行为中,只有登录界面是在设置session前可以访问的,因此webapp下面的login.jsp不会被权限过滤,任何情况均可访问

因此登录行为自己建立一个LoginServlet

登录和注销,两个行为都会更改session状态,因此各自一个Servlet处理业务

其他的用户行为都需要登录权限,因此在jsp目录下,收到权限过滤器保护,这些行为都有session状态,都发往一个UserServlet

通过一个method参数,决定该UserServlet调用何种服务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
String method = req.getParameter("method");
if ("savepwd".equals(method) && method != null) {
this.updatePwd(req, resp);
} else if ("pwdmodify".equals(method) && method != null) {
this.pwdmodify(req, resp);
} else if ("query".equals(method) && method != null) {
this.userQuery(req, resp);
} else if ("add".equals(method) && method != null) {
this.add(req, resp);
} else if ("getrolelist".equals(method) && method != null) {
this.getRoleList(req, resp);
} else if ("ucexist".equals(method) && method != null) {
this.userCodeExist(req, resp);
} else if ("view".equals(method) && method != null) {
this.getUserById(req, resp, "userview.jsp");
} else if ("modify".equals(method) && method != null) {
this.getUserById(req, resp, "usermodify.jsp");
} else if ("modifyexe".equals(method) && method != null) {
this.userModify(req, resp);
} else if ("deluser".equals(method) && method != null) {
this.deleteUser(req, resp);
}
}

实际上是控制耦合,这个method参数就是控制开关

Bill和Provider各自只有一个Servlet,也在doGet方法上采用控制耦合

总的来说,尽量少建立Servlet,可以使用控制耦合,大体上还是一个数据表对应一个Servlet,所有发生在该表上的行为,用一个Servlet处理,通过method参数的不同区分Service层的服务

建立Service层

Controller层建立完毕之后,Service实际上已经固定了

User.getUserById给定一个用户名,查库建立一个user pojo,从控制层到服务层到数据访问层逐层往下扔就是了

1
2
3
4
5
6
7
8
9
10
11
12
Controller层User.getUserById
private void getUserById(HttpServletRequest req, HttpServletResponse resp, String url) throws ServletException, IOException {
//获取传入的id
String uid = req.getParameter("uid");
if (!StringUtils.isNullOrEmpty(uid)) {
UserServiceImpl userService = new UserServiceImpl();
User userById = userService.getUserById(uid);//往下扔
req.setAttribute("user", userById);
req.getRequestDispatcher(url).forward(req, resp);
}
}

建立DAO层

每一个数据表都会对应一个pojo类,一个Dao层类

每一个数据表都会对应一个pojo类,一个Dao层类

每一个数据表都会对应一个pojo类,一个Dao层类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Dao/
│ DaoBase.java

├─bill
│ BillDao.java
│ BillDaoImpl.java

├─provider
│ ProviderDao.java
│ ProviderDaoImpl.java

├─role
│ RoleDao.java
│ RoleDaoImpl.java

└─user
UserDao.java
UserDaoImpl.java

比如用户表user,对应一个pojo User,然后在Dao/user/下面有一个UserDao接口和一个UserDaoImpl实现

这个UserDao接口干了啥呢?

image-20230104200556894

每一个函数,都对应于一种user表中的原子操作(一条sql语句)

每一个函数,都对应于一种user表中的原子操作(一条sql语句)

每一个函数,都对应于一种user表中的原子操作(一条sql语句)

UserDao函数 user表原子操作
getLoginUser( connection, userCode) select * from smbms_user where userCode=?
updatePwd(connection, id, password) update smbms_user set userPassword = ? where id = ?
add(Connection connection, User user) insert into smbms_user (userCode,userName,userPassword," + "userRole,gender,birthday,phone,address,creationDate,createdBy) " + "values(?,?,?,?,?,?,?,?,?,?)
... ...

"中途优化是万恶之源"

等整个网站正常运行起来之后,再考虑如何优化,到那时候再考虑使用什么Druid连接池

最初就用最普通的mysql就可以了

Service层:

动态规划-区间DP

模板题一般长这样:

给一个区间[1,n]

给定一种操作:合并子区间,两个相邻子区间[i,j],[j+1,k]合并,得到一定的收益或者付出一些代价.这个收益或者代价必然是和两个子区间相关的函数,比如sum[i,j]+sum[j+1,k]

求将整个区间合并为一个数,获得的最大收益或者付出的最小代价

P1880 合并环状石子

一圈石子有n组,其中第i组有a[i]个,合并两组石子的代价是两组石子个数和,求将所有石子合并成一组需要的最大代价和最小代价

本题需要先把环拆成线,其弱化版也就是无环版本P1775 石子合并(弱化版

首先需要把这个圈解开,比较好的方法是将[1,n]展开成线性,然后复制一遍接到后面,就是

总下标 1 2 3 ... n-1 n n+1 n+2 n+3 ... 2n-1 2n
原下标 1 2 3 ... n-1 n 1 2 3 ... n-1 n
石子个数 a[1] a[2] a[3] .. a[n-1] a[n] a[1] a[2] a[3] ... a[n-1] a[n]

设计状态

\[ max\_dp[i][j]=将[i,j]区间合并成一组的最大代价\\ min\_dp[i][j]=将[i,j]区间合并 成一组的最小代价 \]

显然边界条件是: \[ \forall _{1\le i\le 2n}max\_dp[i][i]=0\\ \forall _{1\le i\le 2n}min\_dp[i][i]=0\\ \] 意思是将一组石子和自己合并(合并个寂寞),其代价为0,因为不需要合并

\[ max\_dp[i][j]=\max_{i\leq k<j}(max\_dp[i][k]+max\_dp[k+1][j])+\sum_{i\leq l\leq j}a[l]\\ min\_dp[i][j]=\max_{i\leq k<j}(min\_dp[i][k]+min\_dp[k+1][j])+\sum_{i\leq l\leq j}a[l] \]

其中\(\sum_{i\leq l\leq j}a[l]\)也就是从i到j的石子总个数,可以维护前缀和降低复杂度 \[ \sum_{i\leq l\leq j}a[l]=prefix[j]-prefix[i-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
43
44
45
46
47
48
49
50
#include <iostream>
#include <algorithm>
using namespace std;
int n;
const int INF=1e9;
const int maxn=210;
int value[maxn];
int max_dp[maxn][maxn];//max_dp[i][j]为合并闭区间[i,j]所需要的最大代价
int min_dp[maxn][maxn];
int prefix[maxn];
int main(){
cin>>n;

int v;
prefix[0]=0;
for(int i=1;i<=n;i++){
cin>>v;
value[i]=value[i+n]=v;
prefix[i]=prefix[i-1]+value[i];
max_dp[i][i]=max_dp[i+n][i+n]=0;//初始化边界条件,只有一堆时不需要合并,因此无代价
min_dp[i][i]=min_dp[i+n][i+n]=0;
}
for(int i=1+n;i<=n*2;i++){
prefix[i]=prefix[i-1]+value[i];
}

for(auto delta=1;delta<n;delta++){//delta,步长,确定区间左端点i之后,右端点j=i+delta,显然delta初始值应该是1
for(auto i=1;i+delta<=2*n;i++){
auto j=i+delta;
auto temp_max=0;
auto temp_min=INF;//临时变量,用于计算最值
for(auto k=i;k<j;++k){
temp_max=max(temp_max,max_dp[i][k]+max_dp[k+1][j]+prefix[j]-prefix[i-1]);//状态转移
temp_min=min(temp_min,min_dp[i][k]+min_dp[k+1][j]+prefix[j]-prefix[i-1]);
}
max_dp[i][j]=temp_max;
min_dp[i][j]=temp_min;
}
}
int max_value=0;
int min_value=1e9;
for(int i=1;i<=n;i++){
max_value=max(max_value,max_dp[i][i+n-1]);
min_value=min(min_value,min_dp[i][i+n-1]);
}
cout<<min_value<<endl<<max_value<<endl;


return 0;
}

P1063 合并能量珠子

每个珠子有两头,记作(l,r),相邻两个珠子的相向两头数值相同,比如 \[ (a,b)(b,c)(c,d)(d,a) \] 合并两个相邻珠子\((a,b)(b,c)\)得到的能量是\(a\times b\times c\),合并之后形成新珠子\((a,c)\)

将整个项链合并,求得到的最大能量值

还是将整个环拆成[1,n]然后拷贝一份接到后面

\(dp[i][j]\)表示合并区间[i,j]能够得到的最大能量,其中i表示第i个珠子,j表示第j个珠子

边界条件仍然是\(dp[i][i]=0\),只有一个珠子不需要合并

状态转移: \[ dp[i][j]=\max_{i\le k< j}(dp[i][k]+dp[k+1][j]+node[k].l\times node[k].r\times node[k+1].r) \]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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 <algorithm>
#include <vector>
#include <string>
using namespace std;
const int maxn=210;
int n;
struct Bead{
int __energy;//合并成当前珠子时能够获得的最大能量
int __l;//左标记
int __r;//右标记
Bead(int _l=0,int _r=0,int _e=0):__l(_l),__r(_r),__energy(_e){}
int l()const{
return __l;
}
int r()const{
return __r;
}
void setL(int _l){
__l=_l;
}
void setR(int _r){
__r=_r;
}
void setEnergy(int _e){
__energy=_e;
}
int energy(){
return __energy;
}
string toString()const{
return "("+to_string(__l)+","+to_string(__r)+")";
}
friend ostream &operator<<(ostream &os,const Bead &bead){
os<<bead.toString();
return os;
}
}bead[maxn][maxn];
int dp[maxn][maxn];


int main(){
cin>>n;
int l;
int r;
for(int i=1;i<=n;i++){
cin>>l;
bead[i][i].setL(l);
bead[i+n][i+n].setL(l);
bead[i+n-1][i+n-1].setR(l);
bead[i-1][i-1].setR(l);
}
bead[2*n][2*n].setR(bead[0][0].r());
for(auto delta=1;delta<n;++delta){
for(auto i=1;i+delta<=2*n;++i){
auto j=i+delta;
int max_energy=0;
int temp_energy=0;
int max_k=0;
for(auto k=i;k<j;++k){

temp_energy=max(max_energy,bead[i][k].energy()+bead[k+1][j].energy()+bead[i][k].l() * bead[i][k].r() * bead[k+1][j].r());
if(temp_energy>max_energy){
max_energy=temp_energy;
max_k=k;
}
}
bead[i][j].setEnergy(max_energy);
bead[i][j].setL(bead[i][max_k].l());
bead[i][j].setR(bead[max_k+1][j].r());
}
}
int result=0;

for(int i=1;i<=n;i++){
int j=i+n-1;
result=max(result,bead[i][j].energy());
}
cout<<result<<endl;
return 0;
}

P1005 矩阵取数

对于一个给定的 \(n \times m\) 的矩阵,矩阵中的每个元素 \(a_{i,j}\) 均为非负整数。游戏规则如下:

  1. 每次取数时须从每行各取走一个元素,共 \(n\) 个。经过 \(m\) 次后取完矩阵内所有元素;
  2. 每次取走的各个元素只能是该元素所在行的行首或行尾;
  3. 每次取数都有一个得分值,为每行取数的得分之和,每行取数的得分 = 被取走的元素值 \(\times 2^i\),其中 \(i\) 表示第 \(i\) 次取数(从 \(1\) 开始编号);
  4. 游戏结束总得分为 \(m\) 次取数得分之和。

对于任意矩阵,可以求出取数后的最大得分。

由于第i次取数的权重为\(2^i\),显然应尽量保留大数放到最后取

并且取第一行和第二行的数没有影响

因此实际上可以先解决一行内的取数问题,问题转化为:

m个数,第i个数是a[i],每次取数只能从头或者尾取数,第j次取数x的得分是\(2^j\times x\),求m次取数最高得分

如果没有只能头尾取数的限制,显然直接m个数排序后按照从小到达的顺序取数,现在有一个只能从头或者尾取数的限制

暴力搜索的算法容易想到dfs(deque,level)表示当前双端队列deque,当前要取第level个数

但是m最大为80,这就意味着dfs的时间复杂度是\(O(2^{80})\)显然超时

显然要使用更吊的方法

设计状态: \[ dp[i][j]\leftarrow要在闭区间[i,j]中取数,全部取完后最终能够得到的最大值 \]

状态转移: \[ dp[i][j]=max(dp[i][j-1]+a[j][j]\times 2^k,dp[i+1][j]+a[i][i]\times 2^k) \] 其中k是第k次取数,k=n-i-j+2

dp[1][n]是第1次取数

屑题害得考尼玛的高精度,有个🔨意思啊,没活可以咬打火机

P4767 邮局

v个村庄,p个邮局

第i个村庄在a[i]位置

邮局都要建在村庄里,要求所有村庄到最近邮局的距离和最小

求这个最小距离和是多少

设计状态: \[ dp[i][j]表示前i个村庄放了j个邮局,最小距离和 \]

状态转移 \[ dp[i][j]=\min_{1\le k\le i}(dp[k][j-1]+w(k+1,i)) \] 其中\(w(l,r)\)表示,在第l个和第r个(包括两头)村庄之间放一个邮局时,这一段上的村庄的最短距离和

这个玩意儿怎么求呢?

\([l,r]\)区间上的村庄坐标为\(x[l],x[l+1],x[l+2],...,x[r-1],x[r]\)

假设放这个邮局的坐标为\(x\),则问题相当于 \[ w(l,r)=min(|x-x[l]|+|x-x[l+1]|+|x-x[l+2]|+...+|x-x[r-1]|+|x-x[r]|) \]\(f(x)=|x-x[l]|+|x-x[l+1]|+|x-x[l+2]|+...+|x-x[r-1]|+|x-x[r]|\)

也就是求该函数在\([l,r]\)上的最小值点

怎么求呢?画图观察一下,

如果有奇数项,比如\(y=|x-x_1|+|x-x_2|+|x-x_3|\),这个函数必然有一个尖儿,也就是我们需要的最小值,并且这个极小值点等于 \(中位数(x_1,x_2,x_3)\)

image-20221223163332854

如果有偶数项,比如\(y=|x-x_1|+|x-x_2|+|x-x_3|+|x-x_4|\),就没有这个尖儿,但是有一个水平的最低谷,这个最低谷介于两个中位数之间

image-20221223163546294

这就有\(w(l,r)\)的求解方法了:

1
2
3
4
5
6
7
w(l,r):
mid=向下取整((l+r)/2)
dist_sum=0;
for all k among [l,r]:
dist_sum+=abs(x[k]-x[mid])

return dist_sum;

每次调用\(w(l,r)\),时间复杂度都是\(T(r-l+1)=O(n)\)

可以预处理所有的\(w(l,r)\)

1
2
3
4
5
for(int l=1;l<=n;++l){
for(int r=l;r<=n;++r){
W[l][r]=w(l,r);
}
}

外面又套了\(O(n^2)\)的循环

总复杂度是\(O(n^3)\),然而n最大是3000,显然\(O(n^3)\)会超时

如果能给他降为\(O(n^2)\)就可以了

这个\(w[l,r]\)的求法可以继续优化

假设已经知道了\(w[l,r-1]\),现在要求\(w[l,r]\) \[ w[l,r]=w[l,r-1]+x[r]-x[mid(l,r)]\\ mid(l,r)=\lfloor\frac{l+r}{2}\rfloor \] 这条结论看上去很诡异,太简洁了,并且好像中位数的变动只会影响到新加入的第r个村庄,前面的[l,r-1]村庄到邮局的最近距离和仍然是使用的原来的中位数,我自己推导的式子中是有中位数的变化的

如果\(r-l+1\)是一个奇数,那么最右边再加一个,下标中位数不变,相当于邮局位置不用改动,那么[l,r]所有的村庄到这个邮局的距离和不变,\(w[l,r+1]=w[l,r]+|x[r+1]-x[mid]|\)

如果\(r-l+1\)是一个偶数,那么最右边再加一个,下标中位数得往右移动一个,新mid=老mid+1,那么

\(w[l,r+1]=w[l,r]+|x[新mid]-x[老mid]|\times (l-r+1)+|x[r+1]-x[新mid]|\)

再考虑\(r-l+1\)是奇数的情况,如果保持下标中位数不变,实际上对于[l,r+1]时,这个中位数是左中位数

完全可以+1使用右中位数

因此两种情况统一了,即 \[ w[l,r+1]=w[l,r]+(x[mid+1]-x[mid])\times (r-l+1)+x[r+1]-x[mid+1] \]

然而仔细一想确实可以忽略中位数的变化.怎么想呢?

\[ \begin{aligned} w[l,r-1]&=\sum_{l\le k\le r-1}|x[k]-x[mid(l,r-1)]|\\ w[l,r]&=\sum_{l\le k\le r}|x[k]-x[mid(l,r)]|\\ \end{aligned} \] 如果[l,r-1]之间有偶数个村庄,那么选取邮局建立在左中位数村庄或者右中位数村庄,甚至两者直接的地方(虽然只允许建立在村庄里),[l,r-1]所有的村庄到这个邮局的距离和都是最短的,这在图上对应水平谷底那一段

不妨让邮局建立在右中位数村庄,也就是\(\lfloor\frac{l+r-1}{2}\rfloor+1\)

那么[l,r]有奇数个村庄,其中位数唯一,也就是\(\lfloor \frac{l+r}{2}\rfloor\)

由于[l,r-1]之间有偶数个村庄,因此可以设\(r-1-l+1=r-l=2k\)带入上两式 \[ \begin{aligned} \lfloor\frac{l+r-1}{2}\rfloor+1&=\lfloor k+l-\frac{1}{2}\rfloor+1=k+l-1+1=k+l\\ \lfloor \frac{l+r}{2}\rfloor&=\lfloor k+l\rfloor=k+l \end{aligned} \] 这就证明了[l,r]的中位数恰好是[l,r-1]的右中位数,因此两种情况可以选择同一个中位数,因此新加入r村庄的时候w[l,r]直接加上w[l,r-1],无需进行中位数的校正

如果[l,r-1]之间有奇数个村庄,那么只需要让[l,r]选取左中位数和[l,r-1]的中位数是一个点,同理可证

证毕

实际上证完了看这个结论很容易想,但是之前思维就太僵化了,以后记住这个结论吧

加上w计算的优化,加上四边形不懂事的优化

okk,现在dp的时间复杂度成功降到了\(O(n^2)\)

优化后的w,其计算的时间复杂度也是\(O(n^2)\)

总时间复杂度就是\(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
77
#include <iostream>
#include <algorithm>
using namespace std;
int v, p;
const int INF = 1e9;
const int maxv = 3010;
const int maxp = 310;
int x[maxv];
int w[maxv][maxv];
int dp[maxv][maxp];
// int s[maxv][maxp];
int s[maxv];
int main()
{
// cin.tie(0);
cin >> v >> p;
for (int i = 1; i <= v; ++i)
{
cin >> x[i];
}

sort(x + 1, x + v + 1);//使村庄的坐标升序排列

//初始化w数组
for (int l = 1; l <= v; ++l)
{
w[l][l] = 0; // 从l号村庄上建立邮局,到该村庄的最短邮局距离就是0
for (int r = l + 1; r <= v; ++r)
{
w[l][r] = w[l][r - 1] + x[r] - x[(l + r) >> 1];
}
}


//初始化dp数组
for (int i = 0; i <= v; i++)
{
s[i]=i;
for (int j = 0; j <= p; ++j)
{
dp[i][j] = INF;
}
}
dp[0][0] = 0;


for (auto j = 1; j <= p; ++j)
{
for (auto i = 1; i <= v; ++i)
{
int temp_min = INF;
for (auto k = s[i-1]; k < i; ++k)
{
if(temp_min>dp[k][j - 1] + w[k + 1][i]){
temp_min=dp[k][j - 1] + w[k + 1][i];
s[i]=k;//四边形不等式优化
}
// temp_min = min(temp_min, dp[k][j - 1] + w[k + 1][i]);
}
dp[i][j] = temp_min;
}
}

// i,j遍历顺序反了,导致计算dp[i][j]时,dp[k][j-1]可能还没有经过计算
// for(auto i=1;i<=v;++i){
// for(auto j=1;j<=p;++j){
// int temp_min=INF;
// for(auto k=1;k<i;++k){
// temp_min=min(temp_min,dp[k][j-1]+w[k+1][i]);
// }
// dp[i][j]=temp_min;
// }
// }
cout << dp[v][p];

return 0;
}

动态规划-四边形不等式优化

证明及其他详细资料见luoyongjun999/code: code (github.com)

区间dp的状态转移,一般长这样(以P1775石子合并为例):

1
2
3
4
5
6
7
8
9
10
11
12
13
for(int delta=1;delta<=n;++delta){
for(auto i=1;i+delta<=n;++i){
auto j=i+delta;
int temp_min=INF;
for(auto k=i;k<j;++k){
if(dp[i][k]+dp[k+1][j]<temp_min){
temp_min=dp[i][k]+dp[k+1][j];
s[i][j]=k;
}
}
dp[i][j]=temp_min+prefix[j]-prefix[i-1];
}
}

三层循环,复杂度\(O(n^3)\)

使用四边形不等式优化(能够证明成立的前提下),可以将最里圈k的遍历范围从\([i,j)\),缩减到\([s[i][j-1],s[i+1][j]\),这个范围结合上i和j的范围能够证明,三层循环的时间复杂度实际上是\(O(n^2)\)

这里\(s[i][j]\)的实际意义是,当\(dp[i][j]=\min_{i\leq k<j}(dp[i][k]+dp[k+1][j])+\sum_{i\leq k\leq j}a[k]\)在k处取得最小值时,此时的k就作为\(s[i][j]\)

边界条件是\(s[i][i]=i\)

用法见P1775

P1775 石子合并(弱化版)

一排N堆石头第i堆石头有a[i]个,合并两堆石头的得分是两堆石头的数量和

求最后合并只剩一堆时取得的最小得分,其中\(N\le 300,a[i]\leq 1000\)

P1880那个题是环状的,这个是线性的

设计状态: \[ dp[i][j]表示合并[i,j]所有的石子,获得的最小得分 \] 边界条件: \[ dp[i][i]=0,即合并一堆时合并个寂寞 \] 状态转移: \[ dp[i][j]=\min_{i\leq k<j}(dp[i][k]+dp[k+1][j])+\sum_{i\leq k\leq j}a[k] \] 前缀和优化: \[ \sum_{i\leq k\leq j}a[k]=prefix[j]-prefix[i-1]\\ dp[i][j]=\min_{i\leq k<j}(dp[i][k]+dp[k+1][j])+prefix[j]-prefix[i-1] \] 四边形不等式优化:

实际上需要证明\(dp[i][j]\),但是可以证明只要是\(w[i][j]\)满足四边形不等式,\(dp[i][j]\)也自动满足四边形不等式,

因此首先证明\(w[i][j]=\sum_{i\leq k\leq j}a[k]=prefix[j]-prefix[i-1]\)满足应用四边形不等式的条件,即证:

1.定义:对于任意\(i<i+1\leq j<j+1\),都有 \[ w[i][j]+w[i+1][j+1]\leq w[i][j+1]+w[i+1][j] \] 2.单调性:对于任意\(i\le i'\le j\le j'\),都有 \[ w[i][j']\ge w[i'][j] \] 证明如下:

对于定义: \[ w[i][j]+w[i+1][j+1]=prefix[j]-prefix[i-1]+prefix[j+1]-prefix[i] \]

\[ w[i][j+1]+w[i+1][j]=prefix[j+1]-prefix[i-1]+prefix[j]-prefix[i] \]

左=右,定义成立

对于单调性: \[ w[i][j']-w[i'][j]=prefix[j']-prefix[i-1]-prefix[j]+prefix[i'-1]\\ =(prefix[j']-prefix[j])+(prefix[i'-1]-preifx[i-1]) \] 由于\(i\le i'\le j\le j'\),并且前缀函数\(prefix[i]\)单调不减,因此上式非负,即 \[ w[i][j']\ge w[i'][j] \] 证毕

下面就可以使用四边形不等式的性质定理优化了

\[ dp[i][j]=\min_{s[i-1][j]\leq k<s[i][j+1]}(dp[i][k]+dp[k+1][j])+prefix[j]-prefix[i-1] \] 这里\(s[i][j]\)的实际意义是,当\(dp[i][j]=\min_{i\leq k<j}(dp[i][k]+dp[k+1][j])+\sum_{i\leq k\leq j}a[k]\)在k处取得最小值时,此时的k就作为\(s[i][j]\)

边界条件是\(s[i][i]=i\)

\(s[i][j]\)的计算并不是头铁地从i到j遍历k先求\(s[i][j]\),而是跟随dp一起求

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include <iostream>
#include <algorithm>
using namespace std;
const int maxn=310;
const int INF=1e9;
int n;
int a[maxn];
int dp[maxn][maxn];
int s[maxn][maxn];
int prefix[maxn];


int main(){
cin>>n;
for(int i=1;i<=n;++i){
cin>>a[i];
s[i][i]=i;
dp[i][i]=0;
prefix[i]=prefix[i-1]+a[i];
}
for(int delta=1;delta<=n;++delta){
for(auto i=1;i+delta<=n;++i){
auto j=i+delta;
int temp_min=INF;
for(auto k=i;k<j;++k){
if(dp[i][k]+dp[k+1][j]<temp_min){
temp_min=dp[i][k]+dp[k+1][j];
s[i][j]=k;
}
}
dp[i][j]=temp_min+prefix[j]-prefix[i-1];
}
}
cout<<dp[1][n]<<endl;
return 0;
}

至于四边形不等式的正确性,现在还没有完全转阴,啥时候脑子不浑了再看吧...

动态规划-状压DP

状态压缩DP,也就是把局面状态压缩到一个整数中,用整数的运算表示状态转移

模板题一般是,往棋盘上放同一种棋子,并且不能让他们掐架,问题有两种:

一共要放k个,求放法有几种?

最多放多少个?

g++ -std=c++11 -O2

P1896 八王八

一个\(N\times N\)的国象棋盘上放\(k\)个王八,要求这k个王八不能掐架.求有多少种放王八的方法

其中\(1\leq N\leq 9,0\leq k\leq N\times N\)

类似于八皇后问题,但是王八的攻击范围只有周围八格,更近

状态压缩

棋盘的每个格子,要么有王八,要么没王八,因此 可以用1表示有,0表示无的状态

那么整个棋盘可以看成有N行,每行有N个格子

比如一个\(4\times 4\),没有王的棋盘就可以是

1
2
3
4
0 0 0 0
0 0 0 0
0 0 0 0
0 0 0 0

可以将一行压缩为一个整数,最左侧是最低位比如

1 0 0 1 0 1 0 ->0101001b=41

如果想让一行中的王八不掐架,那么不能有任何两个1相邻.这一点怎么判断呢?

假设current_state=41表示当前行状态

那么(current_state&((current_state>>1)|current_state<<1))就可以判断有没有王八掐架

为啥呢?

比如状态41显然是合法的

状态 整数 二进制
current_state 41 0101001
current_state<<1 1010010
current_state>>1 0010100
(current_state<<1)|(current_state>>1) 1010110
current_state 0101001
(current_state&((current_state>>1)|(current_state<<1)) 0000000

如何让相邻两行的王八不掐架呢?

(current_state&((previous_state)|(previous_state<<1)|(previous_state>>1)))

道理类似于不让一行中的王八掐架,上一行的一个王八,那么本行中该王八的左下,正下,右下都不能有王八

状态转移

设计状态:

dp[currnet_row][cnt_king_used][current_state]表示:

已经安排了[1,current_row]的王八,

已经安排了cnt_king_used个王八,

当前行,也就是第current_row的状态是current_state

状态转移方程: \[ \begin{aligned} &dp[current\_row][cnt\_king\_used][current\_state]=\\ &\sum_{previous\_state}dp[previous\_row][cnt\_kings\_used-cnt\_kings\_of[current\_row]\ ][previous\_state] \end{aligned} \] 初始状态:

前0行(不存在这一行),放了0个王八,状态显然是0,此时的摆放方法dp[0][0][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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

long long dp[10][100][2000];//dp[i][j][k]前i行中已经摆了j个王,其中第i行的状态是k.的数量
int n,k;

vector<int>legal_states;
int cnt_kings_in_state[2000];

bool isLegalState(const int &k){
if((k&((k<<1)|(k>>1)))==0){
return true;
}
return false;
}
bool isInvasion(const int &a,const int &b){
return (a&((b<<1)|(b>>1)|b));
}

int main(){
cin>>n>>k;
int current_state;
int cnt_state;
for(int i=0;i<(1<<n);i++){
current_state=i;
cnt_state=0;
while(current_state){
cnt_state+=current_state&1;
current_state>>=1;
}
cnt_kings_in_state[i]=cnt_state;
if(isLegalState(i)){
legal_states.push_back(i);
}
}
dp[0][0][0]=1;
for(int current_row=1;current_row<=n;current_row++){

auto previous_row=current_row-1;

for(auto current_state:legal_states){//当前行合法状态

for(auto previous_state:legal_states){//上一行的合法状态

if(isInvasion(current_state,previous_state)==false){

for(
int cnt_tot_kings=cnt_kings_in_state[current_state];
cnt_tot_kings<=k;
++cnt_tot_kings
){

dp[current_row][cnt_tot_kings][current_state]+=
dp[previous_row][cnt_tot_kings-cnt_kings_in_state[current_state]][previous_state];

}
}
}
}
}
long long result=0;
for(auto current_state:legal_states){
result+=dp[n][k][current_state];
}
cout<<result<<endl;

return 0;
}

P1219 八皇后

\(N\times N\)的棋盘上要放N的皇后,要求N个皇后不能掐架,求多少种放皇后的方法

\(6\leq N\leq 13\)

状态压缩

N行,每行N位,每位0表示没有皇后,1表示有皇后

一个int有32位,足够表示一行

每行的最左侧格子是int的最低位

状态转移

设计状态

\(dfs(col\_state,diagonal\_state,rdiagonal\_state,row)\)

表示当前要放第row行的皇后,

之前所有的皇后控制的所有列存放在col_state中,

之前所有皇后控制的左上到右下的斜线在本行的影响存放在diagonal_state

之前所有皇后控制的右上到左下的斜线在本行的影响存放在人diagonal_state

显然\(dfs(col\_state,diagonal\_state,rdiagonal\_state,row)\)的任务就是,结合前三个参数,在本行找一个安全位置放一个皇后,然后进入下一层dfs

啥意思呢?8

假如现在要放第四行,前三行的皇后如图所示

image-20221222114520664

这三个皇后控制的列对第四行的影响如图

image-20221222114303317

那么此时第四行的2,4,6列就不允许放皇后,

此时col_state=00101010b,(最左侧格子在最低位)表示第2,4,6列不允许使用

前三个皇后在左上到右下的斜线上,对第四行产生的影响如图

image-20221222114701433

即此时diagonal_state=01110000(最左侧格子在最低位),表示第5,6,7列不允许使用

rdiagonal_state同理

下面考虑这三个表示占用状态的参数如何维护

显然col_state最容易维护,只要第i列上有过皇后,col_state的相应二进制位就置1

对于diagonal_state,假设本行的皇后放在第i列,那么他会影响下一行的i+1列,因此从当前行的dfs进入下一行的dfs时,只需要把diagonal_state加上本行的皇后列位置,然后右移1位传递给下层dfs

image-20221222150322875

综上,对于当前行的dfs,放置皇后时需要考虑的因素如图:

image-20221222150632385

状态转移方程采用"推"的方式,从row层推进到row+1层 \[ \begin{aligned} &dfs(col\_state,diagonal\_state,rdiagonal\_state,row)\\ \Rightarrow &dfs(col\_state|current\_state,(diagonal\_state|current\_state)>>1,(rdiagonal\_state|current\_state)<<1,row+1) \end{aligned} \]

完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
int n;
const int maxn=20;
int log_2[10000];
int cnt_success=0;
int limit_state;
int col[maxn];//第i行放在第col[i]列上
int lowbit(int &x){
return x&(-x);//-x为取补,~x为按位取反
}
void print(){
for(int i=1;i<=n;++i){
cout<<col[i]+1<<" ";
}
cout<<endl;
}
void dfs(int column_state,int diagonal_state,int rdiagonal_state,int row){
if(row>n){
++cnt_success;
if(cnt_success<=3){//打印前三个排列
print();
}
return;
}
int capture_state=(column_state|diagonal_state|rdiagonal_state)&limit_state;
int available_state=(~capture_state)&limit_state;//~按位取反,
int position_state;
int position;
while(available_state){
position_state=lowbit(available_state);//lowbit每次先尝试最低位,保证了得到结果的顺序是按照字典序的
position=log_2[position_state];
col[row]=position;
dfs(column_state|position_state,(diagonal_state|position_state)>>1,(rdiagonal_state|position_state)<<1,row+1);
available_state-=position_state;
}
}

int main(){
cin>>n;
limit_state=(1<<n)-1;//规定yi'man
log_2[1]=0;
for(int i=2;i<10000;i++){//初始化log_2对数函数
log_2[i]=log_2[i/2]+1;
}
dfs(0,0,0,1);
cout<<cnt_success<<endl;
return 0;
}

P1879 八将军

\(M\times N\)个格子的矩形地面

然后输入\(M\times N\)个数表明这个地面是否肥沃,1肥沃,0贫瘠

其中\(1\leq M,N\leq 12\)

肥沃的土地可以种草,贫瘠的不行

任何两个草地不能有相邻边,可以有相邻顶角

求有多少种安排方法

问题可以向八王八靠拢,如果种草地可以视为占了一个中国象棋的将军,前后左右四个格子内不允许有第二个将军.贫瘠的格子不允许放棋

如果八王八问题中也有一些棋盘不允许放置棋子则和本题就很相似了

状态压缩

一行最多有12个格子,每个格子要么种草,要么不中,一个二进制位两种状态表示一个格子即可,那么一行只需要12个二进制位,显然一个int有32位足够表示一行的状态

贫瘠的格子如何建模?贫瘠的格子不允许种草就行了呗

状态转移

类比八国王问题,设计dp状态 \[ dp[current\_row][current\_state] \] 表示:

已经安排了[1,current_row]行,

其中第current_row行的状态是current_stata,

满足上述两个条件的不同安排数目是dp[current_row][current_state]

状态转移方程是 \[ dp[current\_row][current\_state]=\\ \sum_{previous\_state}dp[previous\_row][previous\_state] \] 这里要求previous_statecurrent_state两个状态不能相互冲突,也就是上一行的种草格子,下一行这个格子不允许种草

完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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 <iostream>
#include <vector>
#include <iomanip>
#include <algorithm>
using namespace std;
int m,n;
const int maxn=14;
const int mod=1e9;
int row_base_state[maxn];//输入一行土地贫瘠状态之后压缩成一个整数,row_base_state[i]表示第i行的地皮是贫瘠还是肥沃
vector<int> row_legal_state[maxn];//ro_legal_state[i]表示第i行的所有合法状态集合(同行内没有相邻草地并且考虑贫瘠土地)
int row_limit_state;//行状态上限
int dp[maxn][1000000];//dp[current_row][current_state],第1到current_row行都已经安排,其中第current_row行的状态是current_state


int main(){
cin>>m>>n;//m行,每行有n列
row_limit_state=(1<<n)-1;//行状态上限
int single_state;//用于承载一个格子的状态
for(int i=1;i<=m;i++){//获取输入压缩到row_base_state
int row_state=0;//用于压缩一行的状态
for(int j=1;j<=n;j++){
cin>>single_state;
//最先输入的格子权位最高,也就是说最左侧的格子在状态的最高二进制位
row_state=row_state*2+single_state;//行状态=刚才的行状态左移一位加上新格子的状态
}
row_base_state[i]=row_state;//计算得出了第i行的压缩状态
for(row_state=0;row_state<=row_limit_state;++row_state){//计算第i行的所有合法种草状态,第i行的每种合法种草状态都放到row_legal_state[i]中
if((row_state&((row_state<<1)|(row_state>>1)))==0 && ((row_state&((~row_base_state[i])&row_limit_state))==0)){
row_legal_state[i].push_back(row_state);
}

}
}
dp[0][0]=1;//设置dp初始条件,啥也不种的状态只有一种
row_legal_state[0].push_back(0);//第0行没有任何合法状态
for(auto current_row=1;current_row<=m;++current_row){//
auto previous_row=current_row-1;
for(auto current_row_state:row_legal_state[current_row]){
for(auto previous_row_state:row_legal_state[previous_row]){
if((current_row_state&previous_row_state)==0){//两行不能冲突
dp[current_row][current_row_state]+=dp[previous_row][previous_row_state];//状态转移

}
}
}
}
int result=0;
for(int i=0;i<=row_limit_state;i++){
result=(result+dp[m][i])%mod;//第m行的所有状态都要考虑在内
}
cout<<result<<endl;
return 0;
}

P2704 八炮兵

给一个P和H表示的地形地图

img

其中只有P(Plain,平原)格子可以放炮兵,H山地不行

状态压缩

一个炮兵的打炮范围是横竖的两格相当于中象里将军腿再长一点

实际上和P1879棒子地很相似了,P1879种草的方格相当于放了有一个中象的将军,这个题的格子相当于放了一个壮一点的将军

原来一个将军顶多影响跟前的四个格子,现在一个炮可以影响8个格子,

不妨把跟前这四个格子叫做"一级影响",稍微远一点的四个格子叫做"二级影响",如下图示意

image-20221222194252888

可见一个将军和一个炮的区别就是,将军只有一级影响,炮还有二级影响

P1879这个题中要安排一行时,只需要考虑上一行给这一行造成的一级影响.

本题中除了一级影响,还要考虑二级影响,因此本题可以看作P1879的推广,

状态转移

原来的状态dp[current_row][current_state]表示1到current_row行已经安排完毕,其中current_state是第current_row行的状态,也就是对current_row+1行的"一级影响"

因此拓展到有二级影响的情况,应该这样设置状态dp[current_row][current_state][previous_state],意思是

当前已经安排好了1到current_row行,

current_row行的状态是current_state,

previous_row=current_row-1行的状态是previous_state

dp[current_row][current_state][previous_state]等于在前述三个条件下得到的最大炮兵数量 \[ 当前状态的最大炮兵数=max(枚举与当前行不冲突的上一行状态的最大炮兵数+当前行状态贡献的炮兵数) \]

状态转移方程就是:

1
2
3
dp[current_row][current_state][previous_state]=max(
dp[previous_row][previous_state][preprevious_state]+cnt_of_cannons_in_state[current_state];
)

完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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
#include <iostream>
#include <algorithm>
#include <vector>
using namespace std;
const int maxn=110;//最大行数
int n,m;//n行,m列
int row_limit_state;//一行的最大状态
int row_base_state[maxn];//一行中的地形状态,row_base_state[i]为第i行的地形状态
vector<int> row_legal_states[maxn];//只看一行时,该行的所有合法摆放炮兵的状态集合,row_legal_states[i]为第i行所有可以摆放的状态
int cnt_max_cannons;//记录最大炮兵数量
int dp[maxn][1025][1025];
int cnt_cannons_in_state[1025];//cnt_cannons_in_state[state]表示当一行的炮兵状态为state时,放了几个炮兵


int main(){
cin>>n>>m;//n行m列
// if(n)
int single_state;//输入char类型的地形转换成0或者1表示的地形

char terrain;//存放地形输入

row_limit_state=(1<<m)-1;

for(auto row_state=0;row_state<=row_limit_state;++row_state){//预处理cnt_cannons_in_state数组
int row_temp_state=row_state;
int cnt_cannons=0;
while(row_temp_state){
cnt_cannons+=row_temp_state&1;//不停右移直到降为0,只关心最低为有没有1
row_temp_state>>=1;
}
cnt_cannons_in_state[row_state]=cnt_cannons;
}


for(int row=1;row<=n;row++){
int row_state=0;
for(int col=1;col<=m;++col){
cin>>terrain;
if(terrain=='P'){
single_state=1;
}
else{
single_state=0;
}
row_state=row_state*2+single_state;
}
row_base_state[row]=row_state;//保存地形状态

//立刻计算基于当前行的地形状态,可以得出的所有合法的炮兵摆放状态
for(row_state=0;row_state<=row_limit_state;row_state++){
if(
(row_state&(((row_state<<1)|(row_state<<2)|(row_state>>1)|(row_state>>2) )&row_limit_state) )==0//这个炮兵的摆放状态不能自相冲突
&&
(row_state&((~row_base_state[row])&row_limit_state))==0//这个炮兵的摆放状态要满足地形要求
){
row_legal_states[row].push_back(row_state);
// cout<<row_state<<endl;
}
}
}


dp[0][0][0]=1;
row_legal_states[0].push_back(0);//一定要这个初始化,第二行会查第0行的合法状态


for(auto current_state:row_legal_states[1]){//第一行需要特殊处理,因为第一行往前看两行不存在第-1行,不能作为数组下标
dp[1][current_state][0]=cnt_cannons_in_state[current_state];
cnt_max_cannons=max(cnt_max_cannons,dp[1][current_state][0]);//一定不要忘记统计只有一行时的最大炮兵数量,测试点11专门卡这个
}

for(auto current_row=2;current_row<=n;++current_row){//第二行往前看两行正好是第0行,下标访问正常
auto previous_row=current_row-1;//上一行行号
auto preprevious_row=previous_row-1;//上两行行号
for(auto current_state:row_legal_states[current_row]){//枚举当前行合法状态
for(auto previous_state:row_legal_states[previous_row]){//枚举上一行合法状态
for(auto preprevious_state:row_legal_states[preprevious_row]){//枚举上两行合法状态
if(
(previous_state & preprevious_state)==0//前两行的炮兵不能打架
&&
(current_state&(previous_state|preprevious_state))==0//本行和前两行的炮兵不能打架
){
dp[current_row][current_state][previous_state]=max(//状态转移方程
dp[current_row][current_state][previous_state],
dp[previous_row][previous_state][preprevious_state]+cnt_cannons_in_state[current_state]
);
cnt_max_cannons=max(cnt_max_cannons,dp[current_row][current_state][previous_state]);
}
}
}
}
}
// for(auto current_state:row_legal_states[n]){
// cnt_max_cannons=max(cnt_max_cannons,dp[n])
// }
cout<<cnt_max_cannons<<endl;
return 0;
}

P2051 八中象炮

在一个\(n\times m\)的棋盘上,任意放中象的炮,要求任意两个炮不能相互攻击,随便放炮,问最多有多少种放法

啥时候能炮打炮?一条水平或者竖直线上有仨个以上的炮就要打炮了

因此就问题转化为,\(n\times m\)个方格,每行每列可以有0或者1或者2个石头,求有多少种摆放方法

状态压缩

我最初的想法是,每行,每列都记录已经放过几个石头,然后直接深搜

dfs(x,y)是当前要在第x行第y列放石头,首先判断能否放石头,如果能则尝试放上石头然后下一步深搜.

不管能不能放石头,都要有一个直接下一步深搜

1
2
3
4
5
6
7
8
9
10
11
12
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 <algorithm>
#include <vector>
using namespace std;
const int maxn=110;
const int mod=9999973;
int n,m;
int cnt_on_col[maxn];
int cnt_on_row[maxn];
int cnt_result=0;
void DFS(int y,int x){//第y行,第x列
if(y<=n&&x>m){
DFS(y+1,1);
return;
}
if(y>n){
cnt_result=(cnt_result+1)%mod;
return;
}
if(cnt_on_col[x]<2&&cnt_on_row[y]<2){
++cnt_on_col[x];
++cnt_on_row[y];
DFS(y,x+1);
--cnt_on_col[x];
--cnt_on_row[y];
}
DFS(y,x+1);

}
int main(){
cin>>n>>m;
DFS(1,1);
cout<<cnt_result<<endl;

return 0;
}

结果就三个测试点过了,其他全都TLE

狗屁状压,dp[i][j][k]表示前i行中的列,有一个炮的列有j个,有两个炮的列有k个

完事

C++11特性

迈向现代的第一步

2011年的C++标准,叫c++11

使用gcc编译时,加入编译选项-std=c++11即可使用c++11特性

类型推导

auto&decltype

auto

auto 左值=右值;

通过右值的类型推断auto代表的左值类型,必须得有右值才能用auto定义左值

一般用于循环时作为迭代器,比如在解释器遍历符号表时

在lexer.hpp中有这么一个内置符号表

1
2
3
4
5
6
7
//lexer.hpp
static std::vector<Token> build_in_token_table = {
{CONST_ID, "PI", 3.1415926, NULL},
{CONST_ID, "E", 2.71828, NULL},
...
{DRAW, "DRAW", 0.0, NULL},
};

在lexer.cpp中有一个queryTokenTable函数,根据参数字符串查找内置符号表,如果找到相应符号返回其引用

传统的方法是定义一个循环变量i,遍历整个符号表

如果使用auto则更加方便,foreach循环

1
2
3
4
5
6
7
8
9
10
//lexer.cpp
static Token* queryTokenTable(std::string &lexme)
{
for(auto &build_in_table_token:build_in_token_table){//此处一定是auto &,是个引用类型,否则会拷贝构造新对象
if(build_in_table_token.getLexme()==lexme){
return &build_in_table_token;
}
}
...
}

这里使用的是auto &,而不是auto,之前我认为auto既然是自动的,应该可以推导出引用类型.

然而实际上不会,auto会忽略引用类型还有cv属性,因此这里需要手工加上引用类型

decltype

decltype关键字是用于定义同类型变量,

1
2
3
int a=1;
decltype(a) b=2;
decltype(a+b) c=3;

decltype和auto不同的是,decltype完全拷贝其操作数的类型,包括引用和cv属性

对于decltype(expression),这里expression如果是函数调用,则decltype与函数返回值类型相同

右值引用

右值引用

通常getter方法是这样定义的:

1
2
3
4
5
6
int x(){
return __x;
}
int y(){
return __y;
}

其返回值是一个右值,当调用语句结束后,返回值立刻死亡

实际上大多数调用约定(这里是cdecl),函数的返回值放到rax寄存器中

1
int x=point.x();

该行源代码的最后的指令可能是

1
2
call point.x()
mov [rbp+x],rax

意味着需要在调用者栈帧中给左值int a开4字节空间,然后将函数返回值(存在于rax寄存器)付给他

之后rax的任务就完成了,可以立刻用于其他计算,这就意味着函数返回值这个将亡值不复存在了

显然栈中是找不到这个返回值的,他只会临时存在于寄存器中

右值引用的作用是,在调用者栈帧中给返回值开辟一块空间,将该返回值放到这块空间中,此后在调用者中就可以引用"右值"了,实际上是引用的返回值在调用者栈帧中的拷贝,这一点可以通过反编译观察

比如如下程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <iostream>
#include <string>
using namespace std;

class Point{
private:
int __x;
int __y;
public:
Point(int _x=0,int _y=0):__x(_x),__y(_y){}
int x(){return __x;}
int y(){return __y;}
};

int main(){
Point A(2,5);
int &&x=A.x();//注意此处的x是右值引用
int y=A.y();//注意此处的y是普通变量
printf("%p\n",&x);
printf("%p\n",&y);

return 0;
}

1
g++ -std=c++11 main.cpp -O0 -o main 

然后用ida64反编译观察之

1
2
3
4
5
6
7
8
9
10
11
12
lea     eax, [ebp+A]	
mov ecx, eax ;this指针
call __ZN5Point1xEv ; Point::x(void)
mov [ebp+var_10], eax ;返回值首先拷贝给var_10
lea eax, [ebp+var_10]
mov [ebp+x], eax ;然后拷贝给x


lea eax, [ebp+A]
mov ecx, eax ;this
call __ZN5Point1yEv ; Point::y(void)
mov [ebp+y], eax ;返回值直接拷贝给y

有一个明显区别,

int &&x=A.x();这里貌似在调用者栈帧中产生了两个局部变量,var_10x

但是int y=A.y();只产生了一个局部变量y

显然x和y两个变量是地位相等,门当户对的

这个var_10就是右值的拷贝,其存在的目的就是代理右值,使其可以进行取地址运算,实际上取得地址是var_10的地址

问题又来了,这样看,使用右值引用的时候多产生了一个局部变量,并且需要四条指令,而不使用右值引用只需要两条指令.那用了个寂寞啊,越用越慢是吧.

这是因为这里的调用情况是很简单的,有些复杂情况,var_10会发挥重要作用,详见右值引用

完美转发

没看明白

返回值优化

类似于传参时是直接传递引用还是调用拷贝构造函数给形参赋值

返回时也面临相似的局面,比如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#include <iostream>
#include <string>
#include <vector>
#include <iomanip>
using namespace std;

class Point
{
private:
int __x;
int __y;

public:
Point(int _x = 0, int _y = 0) : __x(_x), __y(_y)
{
cout << "constructor called" << endl;
}
Point(const Point &point)
{
__x = point.__x;
__y = point.__y;
cout<<"copy constructor called"<<endl;
}
static Point Origin(){
cout<<"generating origin point..."<<endl;
Point origin(1,1);
cout<<hex<<&origin<<endl;
cout<<"origin point generated!"<<endl;
return origin;
}
int x(){
return __x;
}
int y(){
return __y;
}
};

int main()
{
Point O=Point::Origin();
cout<<&O<<endl;
return 0;
}

主函数里定义了一个Point O,按照之前的认识,Point O=Point::Origin();这句话应该是这样执行的:

首先调用Point::Origin(),这个函数中会调用构造函数

接着Point O=返回值这里要对O调用拷贝构造函数

总共有两次调用构造函数,然而实际上只调用了一次

1
2
3
4
5
6
7
PS C:\Users\86135\Desktop\cpp> g++ -std=c++11 main.cpp  -O0 -o main 
PS C:\Users\86135\Desktop\cpp> ./main
generating origin point...
constructor called
0x60fe88
origin point generated!
0x60fe88

从两次地址的打印来看,两个对象实际上是一个对象,因此Origin函数可以认为返回的是这个对象的指针,从反汇编看确实如此

1
2
3
4
5
6
7
8
9
10
11
main中:
lea eax, [ebp+var_10]
mov [esp], eax ; this
call __ZN5Point6OriginEv ; Point::Origin(void)
lea eax, [ebp+var_10]
mov ecx, eax
Point::Origin中:
mov dword ptr [esp+4], 1 ; int
mov dword ptr [esp], 1 ; this
mov ecx, [ebp+this]
call __ZN5PointC1Eii ; Point::Point(int,int)

列表初始化

在写词法分析器的时候有这么一个符号类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct Token
{
TokenType type;
std::string lexme;
double value;
Func func;

Token(TokenType t = ERRORTOKEN, std::string l = "", double v = 0.0, Func f = NULL) : type(t),
lexme(l),
value(v),
func(f)
{
}
///...其他成员函数
};

现在需要建立一个内建符号表,如果使用老式的写法

1
2
3
4
5
6
vector<Token> built_in_token_table={
Token(CONST_ID,"PI",3.1415926,NULL),
Token(CONST_ID,"E",2.71828,NULL),
...
Token(DRAW,"DRAW",0.0,NULL)
};

每个条目都要显式调用构造函数Token(...)

而如果使用初始化列表

1
2
3
4
5
6
static std::vector<Token> build_in_token_table = {
{CONST_ID, "PI", 3.1415926, NULL},
{CONST_ID, "E", 2.71828, NULL},
...
{DRAW, "DRAW", 0.0, NULL},
};

这样效果和上面一模一样,也会调用构造函数,但是不用写Token(...),实际上也是调用相应参数个数的构造函数

Lambda表达式

在实验室电脑上,开学乎上

智能指针

之前C++和C的堆内存管理责任全在程序员,new了不delete会内存泄漏,new了delete两次会造成二次释放漏洞.

而有些对象到底啥时候释放,可能人也把握不准,比如这次编译原理实验中,词法分析器的getToken函数总是返回一个Token*指针,函数内部会在堆上开一块内存放对象

然而parser在使用完了这个Token之后没有立刻释放,因为我也不知道啥时候一个Token才会真正使用完,比如一个T类型的Token,它作为变量可能会存在一段时间,提前析构了后来就会访问非法内存

于是Token几乎都存在内存泄漏,而我也不知道咋处理

智能指针就解决了这个困扰

智能指针实际上是对普通指针的包装,但是他会对对象有一个引用计数

每当有一个智能指针指向同一块内存的时候,引用计数就会加一,当一个智能指针消亡或者不再指向该内存的时候,引用计数减一,最后一个不再指向该内存的智能指针会发现引用计数从1到0,于是它会自动析构这个对象

显然就不用人操心在啥时候释放对象了

到底咋用呢?写一个链表类意思意思

关键词:make_shared,shared_ptr

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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 <iostream>
#include <string>
#include <vector>
#include <iomanip>
#include <memory>
using namespace std;

struct LinkedNode{
int __key;
shared_ptr<LinkedNode> __next;
LinkedNode(int _key=0,shared_ptr<LinkedNode> _next=nullptr){
__key=_key;
__next=_next;
cout<<"LinkedNode ctor called"<<endl;
}
~LinkedNode(){
cout<<"LinkedNode dtor called"<<endl;
}
shared_ptr<LinkedNode> getNext(){
return __next;
}
int getKey(){
return __key;
}
void setNext(shared_ptr<LinkedNode> _next){
__next=_next;
}
void setKey(int _key){
__key=_key;
}
string toString()const{
return to_string(__key);
}
LinkedNode operator=(const LinkedNode &)=delete;
LinkedNode(const LinkedNode&)=delete;
};
class LinkedList{
shared_ptr<LinkedNode> head;
public:
LinkedList(){
head=make_shared<LinkedNode>(0,nullptr);//注意创建节点不再直接调用构造函数
//使用make_shared<类名>(参数)创建对象,返回值用shared_ptr<类名>类型承载
}
void pushHead(const int &key){
auto node=make_shared<LinkedNode>(key,head->getNext());
head->setNext(node);
}
void popHead(){
head->setNext(head->getNext()->getNext());
}
bool empty()const{
return nullptr==head->getNext();
}
string toString(){
auto p=head->getNext();
string buffer;
while(p!=nullptr){
buffer+=p->toString()+" ";
p=p->getNext();
}
return buffer;
}
};
int main()
{
LinkedList list;
for(int i=0;i<10;i++){
list.pushHead(i);
}
cout<<list.toString()<<endl;
list.popHead();
cout<<list.toString()<<endl;
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
PS C:\Users\86135\Desktop\cpp> g++ -std=c++11 main.cpp -O0 -o main
PS C:\Users\86135\Desktop\cpp> ./main
LinkedNode ctor called
LinkedNode ctor called
LinkedNode ctor called
LinkedNode ctor called
LinkedNode ctor called
LinkedNode ctor called
LinkedNode ctor called
LinkedNode ctor called
LinkedNode ctor called
LinkedNode ctor called
LinkedNode ctor called
9 8 7 6 5 4 3 2 1 0
LinkedNode dtor called
8 7 6 5 4 3 2 1 0
LinkedNode dtor called
LinkedNode dtor called
LinkedNode dtor called
LinkedNode dtor called
LinkedNode dtor called
LinkedNode dtor called
LinkedNode dtor called
LinkedNode dtor called
LinkedNode dtor called
LinkedNode dtor called

没有任何手动delete的地方,但是dtor依然被调用了

shared_ptr<LinkedNode>LinkedNode *,在用法上一模一样,都是使用箭头调用成员函数

但是shared_ptr<LinkedNode>实际上封装了一个LinkedNode *,前者还有其他功能

比如

1
2
auto node=make_shared<LinkedNode>(0,nullptr);
cout<<hex<<node<<" "<<node.get()<<endl;//get返回裸指针LinkedNode*

node可以使用点号调用shared_ptr类的成员函数,使用箭头符号调用的是其托管对象类的成员函数

继承构造函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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 <string>
#include <vector>
#include <iomanip>
using namespace std;

class Point
{
private:
int __x;
int __y;

public:

Point(int _x , int _y ) :
__x(_x), __y(_y)
{
cout << "constructor called" << endl;
}

int x(){
return __x;
}
int y(){
return __y;
}
string toString()const{
return "("+to_string(__x)+","+to_string(__y)+")";
}
};
class TaggedPoint:public Point{
string __tag;
using Point::Point;//继承父类所有构造函数
public:
TaggedPoint(int _x,int _y,string _tag):TaggedPoint(_x,_y){//委托构造函数,也是c++11特性
__tag=_tag;
}
string toString()const{
return __tag+Point::toString();
}

};
int main()
{
TaggedPoint A(1,2);//TaggedPoint本没有两个int的构造函数,但是其父类有
cout<<A.toString()<<endl;
return 0;
}

nullptr

之前表示空指针都使用NULL,而实际上这是个int 0

c++11之后表示空指针的常量是nullptr

1
2
3
4
5
6
7
8
9
10
11
12
13
void func(void *ptr){
cout<<"ptr func called"<<endl;
}
void func(int value){
cout<<"value func called"<<endl;
}

int main()
{
func(nullptr);
func(NULL);//二义性警告,两个重载函数都是次完美匹配,
return 0;
}
1
2
3
4
5
6
7
8
PS C:\Users\86135\Desktop\cpp> g++ -std=c++11 main.cpp  -O0 -o main 
main.cpp: In function 'int main()':
main.cpp:57:14: warning: passing NULL to non-pointer argument 1 of 'void func(int)' [-Wconversion-null]
func(NULL);
^
PS C:\Users\86135\Desktop\cpp> ./main
ptr func called
value func called

final关键字

final修饰的类不允许被继承,不允许虚函数重载

override关键字

override关键字修饰子类成员函数,保证是在重写父类同名函数,否则报错

default构造函数

在之前的c++中,如果一个类没有显式写构造函数,那么编译器会自动加上一个无参的隐式构造函数,这个构造函数几乎啥也不干(在汇编层面上会哦用子类虚表覆盖父类虚表,算是干了点东西,但是源代码层面无法体现)

假如显示定义了一个带参数的构造函数,则编译器不会再隐式添加无参构造函数,这时候就不能无参构造了

比如这样就会报编译错:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <iostream>
using namespace std;
struct Point{
int __x;
int __y;
Point(int _x,int _y):__x(_x),__y(_y){
cout<<"ctor called"<<endl;
}

};
int main(){
Point A;//尝试调用缺省或者无参构造函数失败
return 0;
}

解决方法是另写一个无参的构造函数

在c++11中的解决方法是Point()=default;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <iostream>
using namespace std;

struct Point{
int __x;
int __y;
Point()=default;//解决方法
Point(int _x,int _y):__x(_x),__y(_y){
cout<<"ctor called"<<endl;
}

};
int main(){
Point A;
return 0;
}

这个构造函数不允许有函数体,其作用就相当于缺省构造函数

delete关键字

之前在一个C++类中,即使啥函数没写,编译器也会自动生成至少俩函数

operator=copy ctor

然而我就不想让他生成这俩函数,禁止对象拷贝

1
2
3
in class LinkedNode:
LinkedNode operator=(const LinkedNode &)=delete;
LinkedNode(const LinkedNode&)=delete;

此时主函数中想用一个节点拷贝另一个节点,编译报错

image-20221221205948052

实际应用比如在basic_ostream中,delete用来禁用对char8_t*类型的流输出运算

1
2
3
4
5
6
7
#ifdef __cpp_char8_t // These deleted overloads are specified in P1423.
// don't insert a UTF-8 NTBS
template <class _Traits>
basic_ostream<char, _Traits>& operator<<(basic_ostream<char, _Traits>&, const char8_t*) = delete;
template <class _Traits>
basic_ostream<wchar_t, _Traits>& operator<<(basic_ostream<wchar_t, _Traits>&, const char8_t*) = delete;

explicit关键字

explicit修饰的构造函数,不允许参数隐式类型转换

constexpr关键字

const和constexpr的区别

之前使用的const名为"常量"实际上是"常变量"

const修饰的变量和未经其修饰的变量只有源代码层面的级别,想要修改const变量会被编译器安全检查阻止.然而一旦通过了编译,到了汇编层面,

1
2
int a=10
const int b=10

两者的底层存储没有任何区别

反汇编根本看不出const属性,顶多可以发现对b只有read操作,没有任何write操作

constexpr是真的常量

在编译阶段,编译器把所有能够计算得出的constexpr替换成字面量,constexpr修饰的函数会用其返回值字面量替换函数调用

实在计算不出来的,比如需要链接其他模块函数才能算出来的,编译器就会忽略constexpr修饰,视为普通变量或者函数

enum class

之前的c++中,枚举类型实际上就是给int类型起了个别名

比如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>
using namespace std;

enum Color{
WHITE,BLACK,RED
};
enum Direction{
SOUTH,NORTH,EAST,WEST
};

int main(){
cout<<WHITE<<" "<<SOUTH<<endl;
if(WHITE==SOUTH){
cout<<"yes"<<endl;
}
return 0;
}

编译链接时会警告不同类的枚举相比较

1
2
3
4
5
6
7
8
PS C:\Users\86135\Desktop\cpp> g++ -std=c++11 main.cpp -O0 -o main
main.cpp: In function 'int main()':
main.cpp:14:15: warning: comparison between 'enum Color' and 'enum Direction' [-Wenum-compare]
if(WHITE==SOUTH){
^
PS C:\Users\86135\Desktop\cpp> ./main
0 0
yes

也就是说不同的枚举没有严格的类型区别

c++11就修正了这一点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>
using namespace std;

enum class Color{
WHITE,BLACK,RED
};
enum class Direction{
SOUTH,NORTH,EAST,WEST
};

int main(){
cout<<Color::WHITE<<" "<<Direction::SOUTH<<endl;//不允许打印!<<没有重载Color::WHITE类型
if(Color::WHITE==Direction::SOUTH){//不允许比较!没有重载==运算符
cout<<"yes"<<endl;
}
return 0;
}

非受限联合体

感觉用处不是很大,了解一下POD类型吧

POD类型

Plain Old Data,平凡的老的数据

C的所有数据类型,包括基本数据类型和任何结构体,都是POD,

可以这样理解POD:其内存布局是很规则的,两个同类的POD之间可以直接用memcpy拷贝数据.啥意思呢?

比如

1
2
3
4
5
6
7
8
9
10
typedef struct{
int __x;
int __y;
}Point;
Point A;
Point B;
A.__x=10;
A.__y=20;
memcpy(B,A,sizeof(B));
//此后B.__x=10,B.__y=20

B和A是同类型的POD,那么A直接拷贝给B,A的第一个字节给B的第一个字节,A的第二个字节给B的第二个字节...显然组装起来的B,其前四个字节就是__x,后四个字节就是__y`,毋庸置疑的

c++11中可以使用std::is_trivial<T>::value来检查一个类型是否是POD

比如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>
using namespace std;


typedef struct STRUCTPOINT{
int __x;
int __y;
STRUCTPOINT()=default;//有缺省构造函数仍然是POD
}Point;

struct MyPoint{
int __x;
int __y;
MyPoint(int _x,int _y):__x(_x),__y(_y){}//有非缺省构造函数,不是POD
};

int main(){
cout<<is_trivial<Point>::value<<endl;
cout<<is_trivial<MyPoint>::value<<endl;
return 0;
}
1
2
3
4
PS C:\Users\86135\Desktop\cpp> g++ -std=c++11 main.cpp -O0 -o main
PS C:\Users\86135\Desktop\cpp> ./main
1
0

如果一个类有非缺省构造函数,或者有虚函数,虚基类,或者成员变量有不同的访问修饰或者第一个成员不是自己的,或者有父类并且父类本类都有成员变量,他就是非POD

这一点好理解

有虚函数的类,其基地址开始的4个字节是虚表指针,不是其成员,使用memcpy也会拷贝虚表指针

assertion

编译阶段的断言

static_assert(bool,string_literal)如果前面的bool表达式为真,则通过编译

否则报告后面的字符串字面量,然后终止编译

1
2
3
4
5
using namespace std;
int main(){
static_assert(true,"字符串字面量");
return 0;
}

这样可以通过编译,啥也不会发生

如果这样写就不让通过:

image-20221221214450658

正则表达式

傅里叶变换

一个欠了一年的帐,今天终于还上了

我根本不知道录音的基本原理

\(O(n\lg n)\)内求解多项式乘法

算法导论上的傅里叶变换,是为了在\(O(n\lg n)\)时间内,解决多项式乘法

如果直接让两个多项式卷积,那么朴素方法复杂度显然是\(O(n^2)\)

如何降复杂度呢?使用点值计算

线性代数上可以证明,一个n-1次多项式,可以在其图像上使用n个点唯一确定(插值方法)

也就是说,一个多项式\(A(x)=\sum_{j=0}^{n-1}a_jx^j\)可以用n个点表示

那么两个均为\(n-1\)次的多项式\(A(x),B(x)\),其乘积多项式就得是一个\(2n-2\)次多项式,需要\(2n-2\)个点唯一确定

显然从一个\(n-1\)次多项式上取n-1个点已经足够唯一确定这个多项式了,再取其他点就是冗余信息,对确定表达式没有影响,因此可以在\(A(x),B(x)\)上各取2n-2个点,计算这2n-2个点的积,这就得出了积多项式的点值表示

其中第i个点\(C(x_i,A(x_i)\times B(x_i))\)纵坐标为两个多项式在\(x_0\)处函数值的乘积

画在图上也就是:

普通乘法是我们之前使用的lowB方法,下面曲线救国nb,就求值和插值这两步是真nb,求值和插值为何是\(O(n\lg n)\)呢?

image-20221219191232000

使用之前学过的方法,这两步都是\(O(n^2)\)的,

求值时需要将\(x_0,x_1...\)以此带入\(A(x)\)一共n个自变量,每个自变量求值都是\(O(n)\),因此总共\(O(n^2)\)

插值时需要使用拉格朗日插值法,也是\(O(n^2)\)

拉格朗日插值公式: \[ A(x)=\sum_{k=0}^{n-1}y_k\frac{\Pi_{j\neq k }(x-x_j)}{\Pi_{j\neq k}(x_k-x_j)} \] 其中\((x_0,y_0),(x_1,y_1),...,(x_n-1,y_n-1)\)是多项式\(A(x)\)的点值表示

最外层这个求和已经是\(O(n)\)

用二维数组\(M[k][j]=x_k-x_j\)预处理分母,

分子预先先直接求出\(F(x)=\Pi(x-x_j)\)

那么对于一个给定的k,分母就是n个数的积,\(O(n)\)

分子就是\(F(x)/(x-x_j)\),\(O(n)\)

乘上最外圈的\(O(n)\)得到\(O(n^2)\)

也就是说,之前使用的方法是\(O(n^2)\)

但是使用快速傅里叶变换这种吊法,就能给他降到\(O(n\lg n)\)

怎么降的?选点值的时候有讲究

任意n个不同的点就可以确定一个n-1次表达式.

如果选n个单位复根,然后对系数向量\((a_0,a_1,...,a_{n-1})\)应用离散傅里叶变换DFT,就可以在\(O(n\lg n)\)内完成求值,使用逆DFT变换就可以在\(O(n\lg n)\)完成插值

单位复根儿

"n次单位复根"就是指满足\(\omega^n=1\)的负数\(\omega\),注意是这个\(\omega\),不带那个n次方,一定要注意上下角标

欧拉公式 \[ e^{i\pi}+1=0 \] 第k个n次单位复根是\(\omega_k=e^{\frac{2\pi ik}{n}}\)

显然\(\omega_k^n=(e^{\frac{2\pi ik}{n}})^n=e^{2\pi ik}=((e^{i\pi})^2)^k=((-1)^2)^k=1^k=1\)

n次单位复根儿恰好有n个,也就是\(k=0,1,2,...,n-1\)

这n个画在复平面儿上是很整齐的,比如8次复根就长这样:

任何两个相邻的单位复根之间的角度都是固定的,\(360/n=360/8\)

image-20221219193558548

都落在复平面单位元上

显然8次复根只有8个,如果非要写第九个\(\omega_8^9\),就和\(\omega_8^0\)重合了,

然后书上就说这是加法群\((Z_8,+)\),确实是,除了装了个B对于解决问题没有帮助,甚至没有学过抽代的就蒙蔽了

DFT

求n-1次多项式的点值表示时,就需要选取n个点\((x_0,x_1,x,...,x_{n-1})\),然后求出各自的函数值\((y_0,y_1,...,y_{n-1})\)

如果选取的n个点是n个富哥儿,那么此时有 \[ y_k=A(\omega_n^k)=\sum_{j=0}^{n-1}a_j\omega _n^{jk} \] 以此得到的\(\vec{y}=(y_0,y_1,...,y_{n-1})\)叫做系数向量\(\vec{a}=(a_0,a_1,...,a_{n-1})\)的离散傅里叶变换,

记作\(\vec{y}=DFT_n(\vec{a})\)

笑死,这看上去不就是带入求值吗,选n个富哥有屁用啊?

如果用普通的带入求值,找n个富哥儿和找n个普通人儿没有区别.

富哥儿的作用不能浪费喽,他们和普通值又有啥区别呢?

这就是单位复根儿的几个性质

这几个性质在计算时会体现群论的对称性,这意味着,这n个富哥确实能够比n个普通值携带更多的信息

image-20221219195023551

FFT

fast Fourier transform,快速傅里叶变换

\(O(n\lg n)\)内求解DFT的算法

也就是说,DFT并不是一个求解方法,只是娶了n个富哥作为点值表示

仍然可以使用带入求值这种\(O(n^2)\)的lowB算法求解

而FFT就是一种吊法

他这样变形:

首先n向上取整到最近的2的幂次,这样做是为了保证能够一直二分直到剩1项,反正多取点不会降低精度

后面的n默认就认为是2的幂次了 \[ \begin{aligned} A(x)&=a_0+a_1x+a_2x^2+...+a_{n-1}x^{n-1}\\ &=(a_0+a_2x^2+a_4x^4+...+a_{n-2}x^{n-2})+(a_1x+a_3x^3+a_5x^{5}+...+a_{n-1}x^{n-1})\\ &=A^{[0]}(x^2)+xA^{[1]}(x^2) \end{aligned} \] 也就是说将\(A(x)\)按照偶数项和奇数项分开,其中奇数项都提出一个x来,这样子多项式中的x都是偶数,可以提取\(x^2\)作为自变量,于是得到 \[ \begin{cases} A^{[0]}(x)=a_0+a_2x+a_4x^2+...+a_{n-2}x^{\frac{n}{2}-1}\\ A^{[1]}(x)=a_1+a_3x+a_5x^2+...+a_{n-1}x^{\frac{n}{2}-1} \end{cases} \] 时刻不要忘记在求什么,

我们现在已知的是\(\vec{a}=(a_0,a_1,a_2,...,a_{n-1})\),\(\vec{x}=(\omega_n^0,\omega_n^1,...,\omega_n^{n-1})\)

要求的是\(\vec {y}=DFT_n(\vec{a})\)

现在可以这样算了: \[ A(x_k)=A(\omega_{n}^k)=A^{[0]}(\omega^{2k}_n)+\omega^k_nA^{[1]}(\omega^{2k}_n) \] 注意到我们不容易计算\(A(x)\)是因为,它的项太多了,0次项,1次项等等一直到n-1次项,共n项

而现在对于一个\(A^{[0]}(x)\)他只有\(\frac{n}{2}\)项了,工作量直接下降到一半

以此递归,总会有个时候,\(A(x)\)只有一项,也就是0次项,也就是那个常数项,算也不用算直接返回了,和x没有关系了,和富哥没关系了

写出伪代码:

注意FFT函数的输入是目标多项式的系数向量,不是富哥,因为富哥是作为常数使用的,不需要参数传递

FFT函数输出的是\(\vec{y}=DFT(\vec{a})\)也就是和将n个富哥直接带入多项式求值得到的n个值相同,但是算法更好

一定注意函数不是y=A(x),而是y=FFT(a)

一定要清楚函数在干什么

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
vector<int> FFT(vector<int> a){//a是系数,返回y向量
n=a.length();
if(1==n){//递归出口条件,只剩一项时,也就是那个常数项,直接返回此时的a,实际上是a[0]作为y[0]返回了y
return a;
};
Complex omega_n=e^(2*pi*i/n);
vector<int> a0,a1;
for(int i=0;i<n;++i){
if(i%2==0)a0.push_back(a[i]);
else a1.push_back(a[i]);
}
int y0=FFT(a0);
int y1=FFT(a1);
vector<int> y(n);
for(int i=0;i<n/2;i++){//一定注意此处上界是n/2
y[i] =y0[i]+pow(omega_n,i)*y1[i];//容易理解
y[n/2+i]=y0[i]-pow(omega_n,i)*y1[i];//想对困难
}
return y;
}

这里面有一个y[n/2+i]=y0[i]-pow(omega_n,i)*y1[i],怎么得到的呢? \[ \begin{aligned} y[\frac{n}{2}+i]&=A(\omega_n^{\frac{n}{2}+i})\\ &=A^{[0]}(\omega_n^{n+2i})+\omega_n^{\frac{n}{2}+i}A^{[1]}(\omega_n^{n+2i})\\ &=A^{[0]}(\omega_n^{2i})+\omega_n^{\frac{n}{2}+i}A^{[1]}(\omega_n^{2i})\\ &=A^{[0]}(\omega_{\frac{n}{2}}^i)-\omega_n^{i}A^{[1]}(\omega_{\frac{n}{2}}^i)\\ &=y_0[i]-\omega_{n}^i y_1[i] \end{aligned} \] 这就体现出富哥和普通点的区别了,富哥的变换很灵活

从上面的伪代码可以看出,最底层的递归和自变量无关.

加速发生在这里,将工程量直接缩减到一半

1
2
3
4
for(int i=0;i<n/2;i++){//一定注意此处上界是n/2
y[i] =y0[i]+pow(omega_n,i)*y1[i];//容易理解
y[n/2+i]=y0[i]-pow(omega_n,i)*y1[i];//想对困难
}

每次递归都将工程量缩减一半,因此总工程量直接对n取对数 \[ T(n)=2T(\frac{n}{2})+\Theta(n)=\Theta(n\lg n) \]

好了到此FFT的思路有了,求值结束,也就是我们求得了\(y=DFT(a)\)

举个例子,\(A(x)=1+2x+x^2+x^3\),正好有\(2^2=4\)项,n=4

4次单位复根\(\omega_4^k=e^{\frac{2\pi ik}{4}}\)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
FFT(<1,2,1,1>){
n=<1,2,1,1>.length()=4;
a0=<1,1>;
a1=<2,1>;
w1;
y0=FFT(<1,1>){
n=<1,1>.length()=2;
a0=<1>;
a1=<1>;
y0=FFT(<1>)=<1>;
y1=FFT(<1>)=<1>;
y[0]=y[0]+w1
}
}

下面考虑如何从点值形式倒回去,也就是如何在\(O(n\lg n)\)实现插值

现在已知的是\(\vec{y}\)\(\vec{x}=\{\omega_{n}^0,\omega_n^1,...,\omega_n^{n-1}\}\),求系数向量\(\vec{a}\)

显然可以待定系数法设n个系数,然后带入自变量和因变量,解一元n次方程组.

高斯消元法是\(O(n^2)\)的lowB算法,要用吊算法

写成矩阵形式

image-20221219211357315

也就是说,一眼顶针,鉴定为范德蒙矩阵,必然可逆且逆矩阵唯一

现在要用尽可能快的方法求解这个范德蒙矩阵\(V\)的逆矩阵,这个求解方法就是算法关键

这有个定理\(V^{-1}_n[j,k]=\frac{\omega_n^{-kj}}{n}\)

怎么得出来的,我线性代数忘了,不会解,但是\(VV^{-1}\)算一下确实得到\(E\),满足互逆矩阵的定义

由于\(\vec{a}=\vec V^{-1}\vec{y}\)

\(a_j\)就等于\(\vec V^{-1}\)的第j行与\(\vec y\)的乘积,也就是说 \[ a_j=\sum_{k=0}^{n-1}V_n^{-1}[j,k]\times y_k\\ =\sum_{k=0}^{n-1}\frac{\omega^{-kj}_n}{n}\times y_k\\ =\frac{1}{n}\sum_{k=0}^{n-1}\omega_n^{-kj}\times y_k \] 求解一个\(a_j\),是\(O(n)\)复杂度的,

共有n个\(a_j\),总共就是\(O(n^2)\)复杂度的,怎么降到\(O(n\lg n)\)呢?

回忆正向的FFT是干啥的来着

是求解\(\vec{y}=DFT_n(\vec {a})\)问题的

其中\(y_j=\sum_{k=0}^{n-1}a_j\omega_n^{jk}\)

类比一下 \[ \begin{aligned} n a_j&=\sum_{k=0}^{n-1} y_k\omega_n^{-jk}\\ y_j&=\sum_{k=0}^{n-1}a_k\omega_n^{jk} \end{aligned} \]\(\vec{y'}=n\vec{a},\)

\(\vec{a'}=\vec{y}\)

令单位富哥变成单位负哥,就可以直接带入FFT函数求解了\(\vec{y'}\)

解出来都除以n就得到了\(\vec{a}\)

因此又在\(O(n\lg n)\)内解决了

回到多项式求积

卷积定理:

对于两个长度为n的向量\(\vec a,\vec b\) \[ a\otimes b=DFT^{-1}_{2n}(DFT_{2n}(a)·DFT_{2n}(b)) \] 将多项式A,B只保留系数看成向量a,b,此时\(a\otimes b\)就是积多项式AB的系数向量

moectf补题

XDSEC/MoeCTF_2022: MoeCTF 2022 Challenges and Writeups (github.com)

reverse

EquationPy

python逆向

pyc与py

在写数据库上机作业时,已经遇见过pyc文件了

1
2
3
4
5
6
7
├─admin
│ │ admin.py
│ │ __init__.py
│ │
│ └─__pycache__
│ admin.cpython-38.pyc
│ __init__.cpython-38.pyc

所有pyc文件都放到__pycache__目录下面,且pyc文件都是和其父目录中的py文件一一对应的

比如这里admin.py对应到admin.cpython-38.pyc

既然cache是缓存的意思,那么可以推测,pyc是py文件编译形成的,用来加速运行的.因为直接运行编译好的二进制文件必然比解释运行py源文件快

查阅资料:

pyc是一种二进制文件,是由py文件经过编译后,生成的文件,是一种byte code,py文件变成pyc文件后,加载的速度有所提高,而且pyc是一种跨平台的字节码,是由python的虚拟机来执行的,这个是类似于JAVA或者.NET的虚拟机的概念。pyc的内容,是跟python的版本相关的,不同版本编译后的pyc文件是不同的,3.7编译的pyc文件,3.6版本的 python是无法执行的。

python命令行编译

1
python -m py_compile main.py

这条命令执行之后就会在当前目录下生成一个子目录__pycache__,其中就有main.cpython-38.pyc文件

这个玩意儿长啥样?

image-20221110123037633

反编译

可以使用在线网站反编译

也可以使用uncompyle6反编译,但是在我电脑上直接卡死

用在线网站反编译之后得到的是一个

1
2
3
4
5
6
7
8
9
10
11

print('Maybe z3 can help you solve this challenge.')
print('Now give me your flag, and I will check for you.')
flag = input('Input your flag:')
if len(flag) == 22 and ord(flag[0]) * 7072 + ord(flag[1]) * 2523 + ord(flag[2]) * 6714 + ord(flag[3]) * 8810 + ord(flag[4]) * 6796 + ord(flag[5]) * 2647 + ord(flag[6]) * 1347 + ord(flag[7]) * 1289 + ord(flag[8]) * 8917 + ord(flag[9]) * 2304 + ord(flag[10]) * 5001 + ord(flag[11]) * 2882 + ord(flag[12]) * 7232 + ord(flag[13]) * 3192 + ord(flag[14]) * 9676 + ord(flag[15]) * 5436 + ord(flag[16]) * 4407 + ord(flag[17]) * 6269 + ord(flag[18]) * 9623 + ord(flag[19]) * 6230 + ord(flag[20]) * 6292 + ord(flag[21]) * 57 == 10743134 and ...
...
and ord(flag[0]) * 5926 + ord(flag[1]) * 9095 + ord(flag[2]) * 2048 + ord(flag[3]) * 4639 + ord(flag[4]) * 3035 + ord(flag[5]) * 9560 + ord(flag[6]) * 1591 + ord(flag[7]) * 2392 + ord(flag[8]) * 1812 + ord(flag[9]) * 6732 + ord(flag[10]) * 9454 + ord(flag[11]) * 8175 + ord(flag[12]) * 7346 + ord(flag[13]) * 6333 + ord(flag[14]) * 9812 + ord(flag[15]) * 2034 + ord(flag[16]) * 6634 + ord(flag[17]) * 1762 + ord(flag[18]) * 7058 + ord(flag[19]) * 3524 + ord(flag[20]) * 7462 + ord(flag[21]) * 11 == 11118093:
print('Congratulate!!!You are right!')
else:
print('What a pity...Please try again >__<')

中间省去了一大段约束条件,因为没有缩进,格式很乱,可以在一个and上ctrl+f2选择所有and,然后移动光标到and最后按回车,相对于刚才稍微明朗点了

image-20221110161830590

实际上整个是一个22元一次方程组,怎么解这个方程组呢?z3

Z3模块

z3用于在约束条件下求一组解

python安装z3模块

官网z3-solver · PyPI

1
pip install z3-solver

之后就可以在python中使用了

1
from z3 import *
使用

从CTF入门z3 solver - 翻车鱼 (shi1011.cn)

[原创]Z3求解约束器及例题-CTF对抗-看雪论坛-安全社区|安全招聘|bbs.pediy.com

Z3Py Guide (ericpony.github.io)

Z3简介及在逆向领域的应用 - 腾讯云开发者社区-腾讯云 (tencent.com)

基本类型
Z3类型 意义
Int 整数
Bool 布尔
Array 数组
BitVec 位域
Real 实数
基本语句
基本语句 意义 例子
Solver 创建一个求解器,可以添加约束条件 s=Solver()
add 添加约束条件
check 检查在给定约束条件下是否有解
model 求出满足所有约束条件的一个解

比如想要求解椭圆\(\frac{x^2}{25}+\frac{y^2}{16}=1\)与直线\(y=x-1\)在y轴右侧的一个焦点

1
2
3
4
5
6
7
8
9
from z3 import *
x=Real('x')
y=Real('y')
s=Solver()
s.add(x*x/25+y*y/16==1)#约束椭圆方程
s.add(y==x-1)#约束直线方程
s.add(x>0)#约束y轴右侧
if(s.check()==sat):
print(s.model())
1
[x = 3.6949050343?, y = 2.6949050343?]
ISCC-2018 reverse-My math is bad

main

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
__int64 __fastcall main(int a1, char **a2, char **a3)
{
puts("=======================================");
puts("= Welcome to the flag access machine! =");
puts("= Input the password to login ... =");
puts("=======================================");
__isoc99_scanf("%s", input);
if ( (unsigned int)check() )
{
puts("Congratulations! You should get the flag...");
flag();
}
else
{
puts("Wrong password!");
}
return 0LL;
}

全局位置有一个字符串input,首先check会检查input,如果通过,则调用flag函数给出flag,其中check和flag中都有加密

这个输入的字符串input在bss节[0x6020A0,0x6020C0)

image-20221002154256280

check

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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
__int64 __fastcall check()
{
__int64 result; // rax
__int64 v1; // [rsp+20h] [rbp-60h]
__int64 v2; // [rsp+28h] [rbp-58h]
__int64 v3; // [rsp+30h] [rbp-50h]
__int64 v4; // [rsp+38h] [rbp-48h]
__int64 v5; // [rsp+40h] [rbp-40h]
__int64 v6; // [rsp+48h] [rbp-38h]
__int64 v7; // [rsp+50h] [rbp-30h]
__int64 v8; // [rsp+58h] [rbp-28h]
__int64 v9; // [rsp+60h] [rbp-20h]
__int64 v10; // [rsp+68h] [rbp-18h]
__int64 v11; // [rsp+70h] [rbp-10h]
__int64 v12; // [rsp+78h] [rbp-8h]

// 要求input长32个字节
if ( strlen(input) != 32 )
return 0LL;
v1 = unk_6020B0; // 0x30303030
v2 = unk_6020B4;
v3 = unk_6020B8;
v4 = unk_6020BC;
// 约束条件1
if ( dword_6020A4 * (__int64)*(int *)input - dword_6020AC * (__int64)dword_6020A8 != 0x24CDF2E7C953DA56LL )
goto LABEL_12;
// 约束条件2
if ( 3LL * dword_6020A8 + 4LL * dword_6020AC - dword_6020A4 - 2LL * *(int *)input != 0x17B85F06 )
goto LABEL_12;
// 约束条件3
if ( 3 * *(int *)input * (__int64)dword_6020AC - dword_6020A8 * (__int64)dword_6020A4 != 0x2E6E497E6415CF3ELL )
goto LABEL_12;
// 约束条件4
if ( 27LL * dword_6020A4 + *(int *)input - 11LL * dword_6020AC - dword_6020A8 != 0x95AE13337LL )
goto LABEL_12;
srand(dword_6020A8 ^ dword_6020A4 ^ *(_DWORD *)input ^ dword_6020AC);
v5 = rand() % 50;
v6 = rand() % 50;
v7 = rand() % 50;
v8 = rand() % 50;
v9 = rand() % 50;
v10 = rand() % 50;
v11 = rand() % 50;
v12 = rand() % 50;
// 约束条件5
if ( v4 * v6 + v1 * v5 - v2 - v3 != 0xE638C96D3LL )
goto LABEL_12;
// 约束条件6,7,8
if ( v4 + v1 + v3 * v8 - v2 * v7 == 0xB59F2D0CBLL
&& v1 * v9 + v2 * v10 - v3 - v4 == 0xDCFE88C6DLL
&& v3 * v12 + v1 - v2 - v4 * v11 == 0xC076D98BBLL )
{
result = 1LL;
}
else
{
LABEL_12:
result = 0LL;
}
return result;
}

前四个约束条件组成方程组可以确定输入的前四个双字,

后面的srand种子就是前四个双字的异或,也可以确定了,然后产生的v5~v12随机数也可以确定了,

下面的运算又可以根据四个约束条件确定后四个双字

1
2
if ( dword_6020A4 * (__int64)*(int *)input - dword_6020AC * (__int64)dword_6020A8 != 0x24CDF2E7C953DA56LL )
goto LABEL_12;

第一个双字和第二个双字的积减去第三个双字和第四个双字的积得是0x24CDF2E7C953DA56

1
2
3
// 约束条件2
if ( 3LL * dword_6020A8 + 4LL * dword_6020AC - dword_6020A4 - 2LL * *(int *)input != 0x17B85F06 )
goto LABEL_12;

\[ (3*input[8:11]+4*input[12:15])-(1*input[4:7]+2*input[0:3])=17B85F06h \]

前四个约束条件就可以写成:

1
2
3
4
dword0*dword1-dword2*dword3==0x24CDF2E7C953DA56
(3*dword2+4*dword3)-(1*dword1+2*dword0)==0x17B85F06
3*dword0*dword3-dword2*dword1==0x2E6E497E6415CF3E
(27*dword1+dword0)-(11*dword3+dword2)==0x95AE13337

写z3脚本解方程

1
2
3
4
5
6
7
8
9
10
11
12
13
from z3 import *

dword0=Int('dword0')
dword1=Int('dword1')
dword2=Int('dword2')
dword3=Int('dword3')
solve(
dword0*dword1-dword2*dword3==0x24CDF2E7C953DA56,
(3*dword2+4*dword3)-(1*dword1+2*dword0)==0x17B85F06,
3*dword0*dword3-dword2*dword1==0x2E6E497E6415CF3E,
(27*dword1+dword0)-(11*dword3+dword2)==0x95AE13337,
)

运行结果

1
2
3
4
5
PS C:\Users\86135\Desktop\reverse> python exp.py
[dword3 = 862734414,
dword1 = 1801073242,
dword2 = 829124174,
dword0 = 1869639009]

下面解出v5~v12的随机数值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <iostream>
using namespace std;
int dword3 = 862734414;
int dword1 = 1801073242;
int dword2 = 829124174;
int dword0 = 1869639009;
int main()
{
srand(dword1 ^ dword2 ^ dword3 ^ dword0);
int v5 = rand() % 50;
int v6 = rand() % 50;
int v7 = rand() % 50;
int v8 = rand() % 50;
int v9 = rand() % 50;
int v10 = rand() % 50;
int v11 = rand() % 50;
int v12 = rand() % 50;
cout<<v5<<endl<<v6<<endl<<v7<<endl<<v8<<endl<<v9<<endl<<v10<<endl<<v11<<endl<<v12<<endl;
return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
┌──(root㉿Executor)-[/mnt/c/Users/86135/Desktop/reverse]
└─# g++ main.cpp -O0 -o main -m32

┌──(root㉿Executor)-[/mnt/c/Users/86135/Desktop/reverse]
└─# ./main
22
39
45
45
35
41
13
36

下面又有四个约束条件

1
2
3
4
v4 * v6 + v1 * v5 - v2 - v3 == 0xE638C96D3LL
v4 + v1 + v3 * v8 - v2 * v7 == 0xB59F2D0CBLL
v1 * v9 + v2 * v10 - v3 - v4 == 0xDCFE88C6DLL
v3 * v12 + v1 - v2 - v4 * v11 == 0xC076D98BBLL

目标是解出v1~v4,对应输入的后四个双字

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from z3 import *
v5=22
v6=39
v7=45
v8=45
v9=35
v10=41
v11=13
v12=36
v1=Int('v1')
v2=Int('v2')
v3=Int('v3')
v4=Int('v4')
solve(
v4 * v6 + v1 * v5 - v2 - v3 == 0xE638C96D3,
v4 + v1 + v3 * v8 - v2 * v7 == 0xB59F2D0CB,
v1 * v9 + v2 * v10 - v3 - v4 == 0xDCFE88C6D,
v3 * v12 + v1 - v2 - v4 * v11 == 0xC076D98BB,
)
1
2
3
4
5
PS C:\Users\86135\Desktop\reverse> python exp2.py
[v1 = 811816014,
v4 = 1195788129,
v2 = 828593230,
v3 = 1867395930]

到此输入全部被解出了,将其转换为字符串

1
2
3
4
5
6
7
8
9
10
11
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>
using namespace std;
int dwords[8] = {
1869639009,
1801073242,
829124174,
862734414,
811816014,
828593230,
1867395930,
1195788129
};
struct Bitfield{
char bytes[4];
int aInt;
Bitfield(const int &x){
bytes[0]=(unsigned char )x;
bytes[1]=(unsigned char)(x>>8);
bytes[2]=(unsigned char)(x>>16);
bytes[3]=(unsigned char)(x>>24);
}
friend ostream &operator<<(ostream &os,const Bitfield &bf){
for(int i=0;i<4;++i){
os<<bf.bytes[i];
}
return os;
}
};
int main(){
for(int i=0;i<8;++i){
Bitfield bf(dwords[i]);
cout<<bf;
}
return 0;
}
1
2
3
PS C:\Users\86135\Desktop\reverse> g++ main2.cpp -O0 -o main2
PS C:\Users\86135\Desktop\reverse> ./main2
ampoZ2ZkNnk1NHl3NTc0NTc1Z3NoaGFG

然后输入到程序就可以得到flag

1
2
3
4
5
6
7
8
9
┌──(root㉿Executor)-[/mnt/c/Users/86135/Desktop/reverse]
└─# ./mathbad
=======================================
= Welcome to the flag access machine! =
= Input the password to login ... =
=======================================
ampoZ2ZkNnk1NHl3NTc0NTc1Z3NoaGFG
Congratulations! You should get the flag...
flag{th3_Line@r_4lgebra_1s_d1fficult!}

回到本题

要使用z3模块解体,首先需要注册变量,比如x=Int("x")这种

而现在22个变量如果手动更换成x,y,z这种字母,太浪费时间,太死脑筋

师傅直接用循环注册了一个flag变量数组

1
2
flag = [Int("input[%d]"%i) for i in range(22)]
s = Solver()

然后怎么添加约束条件呢?利用这个and,再每个等式前面和后面都加上点东西

1
s.add(等式)

就要这种光标排山倒海的效果

image-20221110163329271

把原来有的ord函数也去掉

1
2
3
4
5
6
7
8
9
10
from z3 import *
flag=[Int("flag[%d]"%i)for i in range(22)]
s=Solver()
s.add((flag[0]) * 7072 + (flag[1]) * 2523 + (flag[2]) * 6714 + (flag[3]) * 8810 + (flag[4]) * 6796 + (flag[5]) * 2647 + (flag[6]) * 1347 + (flag[7]) * 1289 + (flag[8]) * 8917 + (flag[9]) * 2304 + (flag[10]) * 5001 + (flag[11]) * 2882 + (flag[12]) * 7232 + (flag[13]) * 3192 + (flag[14]) * 9676 + (flag[15]) * 5436 + (flag[16]) * 4407 + (flag[17]) * 6269 + (flag[18]) * 9623 + (flag[19]) * 6230 + (flag[20]) * 6292 + (flag[21]) * 57 == 10743134)
...
s.add((flag[0]) * 5926 + (flag[1]) * 9095 + (flag[2]) * 2048 + (flag[3]) * 4639 + (flag[4]) * 3035 + (flag[5]) * 9560 + (flag[6]) * 1591 + (flag[7]) * 2392 + (flag[8]) * 1812 + (flag[9]) * 6732 + (flag[10]) * 9454 + (flag[11]) * 8175 + (flag[12]) * 7346 + (flag[13]) * 6333 + (flag[14]) * 9812 + (flag[15]) * 2034 + (flag[16]) * 6634 + (flag[17]) * 1762 + (flag[18]) * 7058 + (flag[19]) * 3524 + (flag[20]) * 7462 + (flag[21]) * 11 == 11118093)
if(s.check()==sat):
result = s.model()
for i in range (22):
print(result[flag[i]])

这样写完之后,最终打印的是result[flag[i]],不是result[i],这是因为一开始是将flag[0]注册为第一个变量,flag[1]注册为第二个变量

这样打印出来的是字符的ascii值,怎么才能打印出字符呢?

1
print(chr(int(str(m[flag[i]]))),end = '')

为了搞明白为啥需要这么多类型转换,总结一下python中int,byte,char,str之间的转换函数

python中的类型转换

函数 说明
int(x [,base ]) 将字符串x转换为⼀个整数
float(x ) 将字符串x转换为⼀个浮点数
complex(real [,imag ]) 创建⼀个复数,real为实部,imag为虚部
str(x ) 将对象 x 转换为字符串
repr(x ) 将对象 x 转换为表达式字符串
eval(str ) ⽤来计算在字符串中的有效Python表达式,并返回⼀个对象
tuple(s ) 将序列 s 转换为⼀个元组
list(s ) 将序列 s 转换为⼀个列表
chr(x ) 将⼀个整数转换为⼀个Unicode字符
ord(x ) 将⼀个字符转换为它的ASCII整数值
hex(x ) 将⼀个整数转换为⼀个⼗六进制字符串
oct(x ) 将⼀个整数转换为⼀个⼋进制字符串
bin(x ) 将⼀个整数转换为⼀个⼆进制字符串

对于int(str,[,base]),base通过一个整数值指定将str理解为10进制或者16进制的字面量进行转换

1
2
3
4
5
6
7
8
>>> int("10",10)
10
>>> int("0x10",10)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
ValueError: invalid literal for int() with base 10: '0x10'
>>> int("0x10",16)
16

如果str="0x10"带了0x前缀,此时base=16可以将str转换为16进制整数,但是尝试转换为10进制就会出错

但是如果str="0x10"则既可以识别为10进制,也可以识别为16进制

回过头来理解print(chr(int(str(m[flag[i]]))),end = '')

这样写首先将一个整数转为str然后又转为int,看似是转了个寂寞,其实一个函数也不能少

chr接收一个int,但是m[flag[i]]实际上是Solver.model()的返回值,是一个modelRef引用.为啥不是整数?因为Solver还可以求解浮点数方程组.因此需要对modelRef不能直接丢给chr

并且modelRef也没有直接丢给int函数,这是因为int函数只接受str作为参数,modelRef没有像str的自动类型转换,因此首先需要调用一个str函数将modelRef转为字符串类型

为啥str函数可以接收这个不是python自己的,而是来自其他模块的类型modelRef?

因为str的参数可以是任何obj对象,显然modelRef是一个对象,可以被str通吃

str干了啥?

str函数会调用该类的__str__函数,如果没有显式定义这个函数,则隐式的__str__函数会将对象的地址信息转换成字符串返回给str函数,然后str再将这个值返回给用户,比如

1
<__main__.A object at 0x0000026784BF8730>

如果有显式实现__str__函数,则str会获得该函数的返回值,比如

1
2
3
4
5
6
7
8
9
class A:
a=''
def __init__(x):
a=x

def __str__(self) -> str:
return "123"
a=A()
print(str(a))

这样运行结果就是123

如果还重载了__repr__函数,则str函数首先调用显式的__repr__函数获得返回值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class A:
a=''
def __init__(x):
a=x
# pass

def __repr__(self) -> str:
return "123"
def __str__(self) -> str:
return "456"


a=A()
print(str(a))

运行结果:456

因此可以估计,Solver.model()函数返回的东西,要么重载了__str__函数,要么重载了__repr__函数

1
2
3
4
5
def model(self):
try:
return ModelRef(Z3_solver_get_model(self.ctx.ref(), self.solver), self.ctx)
except Z3Exception:
raise Z3Exception("model is not available")

如果程序没有问题则返回了一个ModelRef类型的对象

1
2
3
4
5
class ModelRef(Z3PPObject):
...
def __repr__(self):
return obj_to_string(self)
...

这里调用了一个obj_to_string函数,这个函数也是z3模块提供的

1
2
3
4
def obj_to_string(a):
out = io.StringIO()#都是python自带类型,创建一个字符串流对象
_PP(out, _Formatter(a))#z3模块函数,将z3模块对象a按照其实际类型转换后放到out流上
return out.getvalue()#getvalue取得字符串流上的字符串,返回

这里z3模块函数Formatter会根据z3对象a的实际类型,决定返回什么样的字符串

D_flat

给了好多个文件

1
2
3
4
5
6
7
Mode                 LastWriteTime         Length Name
---- ------------- ------ ----
-a---- 2022/7/19 18:10 410 D_flat.deps.json
-a---- 2022/7/19 18:11 5632 D_flat.dll
-a---- 2022/7/19 18:11 149504 D_flat.exe
-a---- 2022/7/19 18:11 10384 D_flat.pdb
-a---- 2022/7/19 18:10 253 D_flat.runtimeconfig.json

我认识的有

dll,动态链接库

exe,主程序,入口点

pdb,符号文件,ida调试时可以载入

我认识json是字符串存储的js对象,但是不知道这里两个json文件可以干啥

用exeinfope查dll文件的壳发现是C#编写的,

用ida64打开D_flat.exe之后就傻眼了,看不到一点flag的痕迹,感觉都是什么.NET平台还有C#那一套东西的环境初始化

师傅说用dnspy反汇编D_flat.dll,刚用dnspy蒙蔽的很,但是转到入口点五个字我还是认得的

image-20221110180846574
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
using System;
using System.Text;

// Token: 0x02000002 RID: 2
internal class D_flate
{
// Token: 0x06000001 RID: 1 RVA: 0x00002050 File Offset: 0x00000250
private static void Main()
{
int f = 0;
int[] flag = new int[]
{
109,
111,
...
33,
125
};
Console.WriteLine("In music theory, there is a note that has the same pitch as D flat.");
Console.WriteLine("Do you know it?\nNow plz input your flag:");
string input = Console.ReadLine();
byte[] byteArray = Encoding.ASCII.GetBytes(input);
for (int i = 0; i < input.Length; i++)
{
if (flag[i] == (int)byteArray[i])
{
f++;
}
}
if (f == flag.Length)
{
Console.WriteLine("TTTTTQQQQQQLLLLLLL!!! This is your flag!");
return;
}
Console.WriteLine("QwQ, plz try again.");
}
}

怎么C#程序长得跟java这么像?类里面才能有main函数是吧?

实际上逻辑很简单,就是输入一串和这个flag数组比一下,如果一模一样则成功

解法也很简单

1
2
3
4
5
6
7
8
9
10
11
#include <iostream>
#include <vector>
using namespace std;
vector<char> flag = {109, 111, 101, 99, 116, 102, 123, 68, 95, 102, 108, 97, 116, 101, 95, 105, 115, 95, 67, 95, 115, 104, 97, 114, 112, 33, 125};
int main()
{
for (auto c : flag)
{
cout << c;
}
}
1
2
3
PS C:\Users\86135\Desktop\moectfwp\MoeCTF_2022-main\Challenges\Reverse\D_flat\D_flat> g++ main.cpp -o main
PS C:\Users\86135\Desktop\moectfwp\MoeCTF_2022-main\Challenges\Reverse\D_flat\D_flat> ./main
moectf{D_flate_is_C_sharp!}

解是解出来了,但是还有几个问题,

1.exe文件是干啥的?主要逻辑都放在dll中了,要exe有何用?

2.我怎么上来就知道应该去dll中找茬?

3.exe中干了啥

chicken_soup

花指令,当时只知道这是花指令,不知道怎么处理花指令,幸好逻辑清晰,看汇编也能分析

实际上把花指令都改成nop指令就可以让ida正确反编译

直接用ida打开程序反编译main函数,输入input要求是38个字符,经过两个加密函数,encrypt1,encrypt2之后,与密文target比较,如果一模一样就通过

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
int __cdecl main(int argc, const char **argv, const char **envp)
{
int result; // eax
char input[100]; // [esp+10h] [ebp-68h] BYREF

puts("I poisoned the program... Can you reverse it?!");
puts("Come on! Give me your flag:");
scanf("%s", (char)input);
if ( strlen(input) == 38 )
{
((void (__cdecl *)(char *))encrypt1)(input);
((void (__cdecl *)(char *))encrypt2)(input);
if ( strcmp(input, &target) )
puts("\nTTTTTTTTTTQQQQQQQQQQQQQLLLLLLLLL!!!!");
else
puts("\nQwQ, please try again.");
result = 0;
}
else
{
puts("\nQwQ, please try again.");
result = 0;
}
return result;
}

target一直,那么问题转化为求两个加密函数的逆

然而这个encrypt1并没有被ida识别为函数,他长得特别丑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
.text:00401000 encrypt1:                               ; CODE XREF: _main+85↓p
.text:00401000 push ebp
.text:00401001 mov ebp, esp
.text:00401003 sub esp, 14h
.text:00401006 push ebx
.text:00401007 push esi
.text:00401008 push edi
.text:00401009 jz short near ptr loc_40100D+1
.text:0040100B jnz short near ptr loc_40100D+1
.text:0040100D
.text:0040100D loc_40100D: ; CODE XREF: .text:00401009↑j
.text:0040100D ; .text:0040100B↑j
.text:0040100D jmp near ptr 13855D9h
.text:0040100D ; ---------------------------------------------------------------------------
.text:00401012 align 4
.text:00401014 dd 8B09EB00h, 0C083F845h, 0F8458901h, 89084D8Bh, 558BF44Dh
.text:00401014 dd 1C283F4h, 8BF05589h, 88AF445h, 83FF4D88h, 8001F445h
.text:00401014 dd 7500FF7Dh, 0F4558BEEh, 89F0552Bh, 458BEC55h, 1E883ECh
.text:00401014 dd 73F84539h, 84D8B1Fh, 0FF84D03h, 8B0151B6h, 45030845h
.text:00401014 dd 8B60FF8h, 558BCA03h, 0F8550308h, 0A3EB0A88h, 8B5B5E5Fh
.text:00401014 dd 0CCC35DE5h, 0CCCCCCCCh
.text:00401080 ; ---------------------------------------------------------------------------

00401009 和0040100B这两个地方的指令很奇怪,跳转到同一个地方,loc_40100D+1,也就是说越过了loc_40100D这个地方的"花指令"

怎么让ida看起来正常点?可以在00401009下断点然后动态调试过去,此时.text:0026100D db 0E9h这条花指令就非常明显了,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
.text:00261000 encrypt1:                               ; CODE XREF: _main+85↓p
.text:00261000 push ebp
.text:00261001 mov ebp, esp
.text:00261003 sub esp, 14h
.text:00261006 push ebx
.text:00261007 push esi
.text:00261008 push edi
.text:00261009 jz short loc_26100E
.text:0026100B jnz short loc_26100E
.text:0026100B ; ---------------------------------------------------------------------------
.text:0026100D db 0E9h
.text:0026100E ; ---------------------------------------------------------------------------
.text:0026100E
.text:0026100E loc_26100E: ; CODE XREF: .text:00261009↑j
.text:0026100E ; .text:0026100B↑j
.text:0026100E mov dword ptr [ebp-8], 0
.text:00261015 jmp short loc_261020
.text:00261017 ; ---------------------------------------------------------------------------
.text:00261017
.text:00261017 loc_261017: ; CODE XREF: .text:00261072↓j
...

把它改成nop空操作指令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
.text:00261000 encrypt1:                               ; CODE XREF: _main+85↓p
.text:00261000 push ebp
.text:00261001 mov ebp, esp
.text:00261003 sub esp, 14h
.text:00261006 push ebx
.text:00261007 push esi
.text:00261008 push edi
.text:00261009 jz short loc_26100E
.text:0026100B jnz short loc_26100E
.text:0026100D nop
.text:0026100E
.text:0026100E loc_26100E: ; CODE XREF: .text:00261009↑j
.text:0026100E ; .text:0026100B↑j
.text:0026100E mov dword ptr [ebp-8], 0
.text:00261015 jmp short loc_261020

然后在encrypt1上按p键转化为函数,就可以用F5反编译了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
unsigned int __cdecl encrypt1(const char *a1)
{
unsigned int result; // eax
unsigned int i; // [esp+18h] [ebp-8h]

for ( i = 0; ; ++i )
{
result = strlen(a1) - 1;
if ( i >= result )
break;
a1[i] += a1[i + 1];
}
return result;
}

一目了然,赏心悦目

出题人怎么出的题?用VC++内联汇编写的

1
2
3
4
5
6
7
8
9
10
11
void enc1(unsigned char* input)
{
__asm {
jz label
jnz label
_emit 0xe9
label:
}
for (int i = 0; i < strlen((const char*)input) - 1; i++)
input[i] += input[i + 1];
}

emit 指令直接在代码段插入数据,反汇编器会直接把插入的数据也当成代码,尝试反汇编,发现没有对应任何指令,寄了.这就是花指令

前面两个jz,jnz保证跳过emit定义的数据,将控制转移到label,顺势执行下面的for循环了

fake_key

分析fake_key.exe

当时通了,但是有一个疑问是,key是啥时候被修改的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
int __cdecl main(int argc, const char **argv, const char **envp)
{
char input[112]; // [rsp+20h] [rbp-80h] BYREF
int lenInput; // [rsp+90h] [rbp-10h]
int lenStr; // [rsp+94h] [rbp-Ch]
int j; // [rsp+98h] [rbp-8h]
int i; // [rsp+9Ch] [rbp-4h]

sub_401800(argc, argv, envp);
lenStr = strlen(key);
puts("I changed the key secretly, you can't find the right key!");
puts("And I use random numbers to rot my input, you can never guess them!");
puts("Unless you debug to get the key and random numbers...");
puts("Now give me your flag:");
scanf("%s", input);
lenInput = strlen(input);
for ( i = 0; i < lenInput; ++i )
input[i] ^= key[i % lenStr];
for ( j = 0; j < lenInput; ++j )
input[j] += rand() % 10;
if ( (unsigned int)strcmp(input, &target) )
puts("\nRight! TTTTTQQQQQLLLLL!!!");
else
puts("QwQ, plz try again.");
return 0;
}

这里key一开始是"yunzh1jun"字样

1
2
.data:0000000000403040 key             db 'yunzh1jun',0        ; DATA XREF: sub_401550+5↑o
.data:0000000000403040 ; sub_401550+2A↑o ...

题目中提示"I changed the key secretly",偷着把key改了,当时在key上看的交叉引用发现确实有个函数会修改key

image-20221110191538085

去sub_401550函数看看

1
2
3
4
5
6
7
8
char *sub_401550()
{
char *result; // rax

result = &Str[strlen(Str)];
strcpy(result, "TCL,trackYYDS");
return result;
}

也就是在原来key后附加了"TCL,trackYYDS".

现在的问题是,这个sub_401550是被谁,再何时调用的?

image-20221110191710527

看交叉引用,该函数的入口被维护在一张RUNTIME_FUNCTION表上,这个表又叫ExceptionDir,看来是

1
2
3
4
5
6
7
8
9
10
11
12
.pdata:0000000000405000 ExceptionDir    RUNTIME_FUNCTION <rva nullsub_1, rva algn_401001, rva stru_406000>
.pdata:000000000040500C RUNTIME_FUNCTION <rva pre_c_init, rva algn_401121, rva stru_406004>
.pdata:0000000000405018 RUNTIME_FUNCTION <rva pre_cpp_init, rva algn_401179, rva stru_40600C>
.pdata:0000000000405024 RUNTIME_FUNCTION <rva sub_401180, rva algn_4014A6, rva stru_406014>
.pdata:0000000000405030 RUNTIME_FUNCTION <rva WinMainCRTStartup, rva algn_4014D2, \
.pdata:0000000000405030 rva stru_406028>
.pdata:000000000040503C RUNTIME_FUNCTION <rva start, rva algn_401502, rva stru_406048>
.pdata:0000000000405048 RUNTIME_FUNCTION <rva sub_401510, rva algn_401529, rva stru_406068>
.pdata:0000000000405054 RUNTIME_FUNCTION <rva sub_401530, rva algn_40153C, rva stru_406070>
.pdata:0000000000405060 RUNTIME_FUNCTION <rva nullsub_2, rva algn_401541, rva stru_406074>
.pdata:000000000040506C RUNTIME_FUNCTION <rva sub_401550, rva strcmp, rva stru_406078>
.pdata:0000000000405078 RUNTIME_FUNCTION <rva strcmp, rva main, rva stru_406084>

这个表也是PE头的导入目录之一

image-20221110192410257

程序基址在0x400000加上0x5000,正好就是ida中的ExceptionDir

查了一下,这是win64上的异常处理Windows X64的Ring3层异常处理机制 | DbgTech

想要彻底弄明白,首先得把异常处理这一套弄明白,留作后话

分析fake_key.elf

还给了一个linux上的程序,这个会修改key的函数放在一个_init_array的表中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
.init_array:0000000000003D88 ; ELF Initialization Function Table
.init_array:0000000000003D88 ; ===========================================================================
.init_array:0000000000003D88
.init_array:0000000000003D88 ; Segment type: Pure data
.init_array:0000000000003D88 ; Segment permissions: Read/Write
.init_array:0000000000003D88 _init_array segment qword public 'DATA' use64
.init_array:0000000000003D88 assume cs:_init_array
.init_array:0000000000003D88 ;org 3D88h
.init_array:0000000000003D88 initArray dq offset loc_11E0 ; DATA XREF: LOAD:0000000000000168↑o
.init_array:0000000000003D88 ; LOAD:00000000000002F0↑o ...
.init_array:0000000000003D90 dq offset changeKey
.init_array:0000000000003D90 _init_array ends
.init_array:0000000000003D90
.fini_array:0000000000003D98 ; ELF Termination Function Table
.fini_array:0000000000003D98 ; ===========================================================================
.fini_array:0000000000003D98
.fini_array:0000000000003D98 ; Segment type: Pure data
.fini_array:0000000000003D98 ; Segment permissions: Read/Write
.fini_array:0000000000003D98 _fini_array segment qword public 'DATA' use64
.fini_array:0000000000003D98 assume cs:_fini_array
.fini_array:0000000000003D98 ;org 3D98h
.fini_array:0000000000003D98 finiArray dq offset __do_global_dtors_aux
.fini_array:0000000000003D98 ; DATA XREF: __libc_csu_init+1D↑o
.fini_array:0000000000003D98 _fini_array ends

这个_init_array表在节头表中

image-20221110193559281

在initArray上按x看交叉引用

image-20221110194420955

发现__libc_csu_init函数会引用该表

这个函数干了啥?

1
2
3
4
5
6
7
8
9
10
11
12
13
void __fastcall _libc_csu_init(unsigned int a1, __int64 a2, __int64 a3)
{
signed __int64 end; // rbp
__int64 begin; // rbx

init_proc();
end = ((char *)&finiArray - (char *)&initArray) >> 3;
if ( end )
{
for ( begin = 0LL; begin != end; ++begin )
((void (__fastcall *)(_QWORD, __int64, __int64))*(&initArray + begin))(a1, a2, a3);
}
}

首先调用了一个init_proc

这个函数判断gmon_start标志是否已经起来了,如果否则调用_gmon_start__函数

参考了

Linux 程序符号__gmon_start__ - 简书 (jianshu.com)

c - What is the "gmon_start" symbol? - Stack Overflow

1
2
3
4
5
6
7
8
9
__int64 (**init_proc())(void)
{
__int64 (**result)(void); // rax

result = &_gmon_start__;
if ( &_gmon_start__ )
result = (__int64 (**)(void))_gmon_start__();
return result;
}

这里的mon应该是monitor,监视器的意思,他好像会监视程序的性能,记录一些数据,反正和程序逻辑关系不大,

然后类似于迭代器,计算end,即init_array的最后一项之后,

end = ((char *)&finiArray - (char *)&initArray) >> 3;这里右移三位相当于除以8,原因是_init_array中维护的都是函数入口地址,每个地址都是8字节的,除以八才得到下标

然后begin从0遍历到end,也就是遍历了这个_init_array

每次遍历都会执行一个((void (__fastcall *)(_QWORD, __int64, __int64))*(&initArray + begin))(a1, a2, a3);

_init_array中的表项,也就是函数入口点,带着(a1,a2,a3)三个参数去执行

显然initArray中有两个表项

1
2
3
4
5
6
7
.init_array:0000000000003D88 _init_array     segment qword public 'DATA' use64
.init_array:0000000000003D88 assume cs:_init_array
.init_array:0000000000003D88 ;org 3D88h
.init_array:0000000000003D88 initArray dq offset loc_11E0 ; DATA XREF: LOAD:0000000000000168↑o
.init_array:0000000000003D88 ; LOAD:00000000000002F0↑o ...
.init_array:0000000000003D90 dq offset changeKey
.init_array:0000000000003D90 _init_array ends

这个changeKey函数是第二个被执行的,显然它用不到a1,a2,a3三个参数.

下面问题是,_libc_csu_init这个函数是干啥的,被谁,在啥时候执行

交叉引用表明只有start函数会调用它

image-20221110195924354

start是程序入口点,它调用了_libc_start_main,这个函数会同时带着main函数和_libc_csu_init函数作为参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// positive sp value has been detected, the output may be wrong!
void __fastcall __noreturn start(__int64 a1, __int64 a2, void (*a3)(void))
{
__int64 v3; // rax
int v4; // esi
__int64 v5; // [rsp-8h] [rbp-8h] BYREF
char *retaddr; // [rsp+0h] [rbp+0h] BYREF

v4 = v5;
v5 = v3;
_libc_start_main(
(int (__fastcall *)(int, char **, char **))main,
v4,
&retaddr,
(void (*)(void))_libc_csu_init,
fini,
a3,
&v5);
__halt();
}

下面问题就是_libc_start_main的逻辑了,这实际上是linux程序从加载到main之前的逻辑,参考程序员的自我修养.留作后话

可以确定的一点是_libc_csu_init是先于main函数执行的,因此main执行之前key已经被修改了

怎么出的题?

1
2
3
4
__attribute((constructor)) static void fun()
{
strcat(key,"TCL,trackYYDS");
}

这个fun函数被__attribute((constructor))修饰,这是GNU编译器的拓展语法

__attribute__介绍

参考attribute((constructor))用法解析 - 简书 (jianshu.com)

__attribute__可以设置函数属性(Function Attribute)、变量属性(Variable Attribute)和类型属性(Type Attribute)。__attribute__前后都有两个下划线,并且后面会紧跟一对原括弧,括弧里面是相应的__attribute__参数

__attribute__语法格式为:attribute ( ( attribute-list ) )

如果函数被设定为constructor属性,则该函数会在main()函数执行之前被自动的执行;若函数被设定为destructor属性,则该函数会在main()函数执行之后或者exit()被调用后被自动的执行。

是一种比较明显的反调试手段

fake_code

又是异常处理,遗留的问题是,如何过滤异常,只对除零异常感兴趣

这样出的题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int FilterFunc(int dwExceptionCode)
{
if (dwExceptionCode == EXCEPTION_INT_DIVIDE_BY_ZERO)
return EXCEPTION_EXECUTE_HANDLER;

return EXCEPTION_CONTINUE_SEARCH;
}
...
__try {
index = (0x7f * index + 0x66) % 0xff;
tmp = index >> 7;
tmp = 1 / tmp;
}
__except (FilterFunc(GetExceptionCode())) {
key = (97 * key + 101) % 233;
key ^= 0x29;
//printf("catch i = %d\n", i);
}

try块中不管发生了什么异常,只要是异常,都会进入except块

这except的参数是调用了FilterFunc函数,给FilterFunc函数传递的参数是GetExceptionCode(),也就是try中发生的异常的类型.

FilterFunc中,当传递的参数是除零异常时,返回EXCEPTION_EXECUTE_HANDLER这么一个枚举值,否则都会返回EXCEPTION_CONTINUE_SEARCH这么一个枚举值

然后except根据FilterFunc的返回值判断是否进入except块,怎么个评判标准呢?

参考Exception-Handler Syntax - Win32 apps | Microsoft Learn

Value Meaning
EXCEPTION_EXECUTE_HANDLER The system transfers control to the exception handler, and execution continues in the stack frame in which the handler is found.
系统将控制移交给异常处理函数,也就是当前函数栈帧中的except块
EXCEPTION_CONTINUE_SEARCH The system continues to search for a handler.
系统将不是用当前函数栈帧中的except块中的异常处理,而是沿着seh链上溯早期注册的异常处理函数,而早期注册的异常处理函数都是系统注册的或者运行环境注册的,没有针对性,相当于是走个过场,也不会对程序逻辑造成很大的影响.
而除零异常会被返回EXCEPTION_EXECUTE_HANDLER,进入云之君自定义的except块,这个块就有实际作用了.
EXCEPTION_CONTINUE_EXECUTION The system stops its search for a handler and returns control to the point at which the exception occurred. If the exception is noncontinuable, this results in an EXCEPTION_NONCONTINUABLE_EXCEPTION exception.
系统忽略异常,控制还给程序异常点继续执行.
如果异常地太厉害执行不动了,将会又触发EXCEPTION_NONCONTINUABLE_EXCEPTION异常

broken_hash

当时的做法(非预期)

当时想到一个非预期解,但是认为BYTE,WORD,还有char,int,unsigned等等这些玩意儿转换太费脑子了就没整,实际上不难

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
puts("This is a surprise!");
printf("Give me your flag: ");
scanf("%s", input);
index = -1i64;
do
++index;
while ( input[index] );
if ( index == 88 )
{
encrypt(input);
for ( i = 0; i < 88; ++i )
{
temp_check = check && encrypted[i] == target[i];
check = temp_check;
if ( !temp_check )
break;
}
v7();
if ( check )
printf("%s", aTtttqqqqqqqlll);
else
printf("%s", aWhatAPityPlzTr);
result = 0;
}

逻辑就是输入要有88个字符,然后encrypt函数对输入进行加密,加密之后密文放在encrypted数组中,再和实现放置的密文target进行比较,全对则通过

关键就在于encrypt这个函数,出题人参考的crypto-algorithms/sha1.c at master · B-Con/crypto-algorithms (github.com).fake_code这个题里面的SHA1加密代码是直接抄的,然后改了几个参数

sha1加密的主逻辑长这样,函数名还有变量名有修改

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
__int64 __fastcall sha1(__int64 text)
{
int i; // [rsp+20h] [rbp-B8h]
char single[12]; // [rsp+24h] [rbp-B4h] BYREF
char ctx[128]; // [rsp+30h] [rbp-A8h] BYREF
unsigned __int8 hash[24]; // [rsp+B0h] [rbp-28h] BYREF

for ( i = 0; i < 88; ++i )
{
single[0] = *(_BYTE *)(text + i); // single=text[i]
sha1_init((__int64)ctx);
sha1_update((__int64)ctx, (__int64)single, 1ui64);
sha1_final((__int64)ctx, (__int64)hash);
encrypted[i] = hash[0] | (hash[1] << 8) | (hash[2] << 16) | (hash[3] << 24);
}
return (unsigned int)check;
}

笑死,sha1加密每次只对一个字符进行,他将输入的88个字符分别进行sha1加密,然后将hash值一通按位或放到encrypted[1]上

对单个字符的加密就失去了sha1加密的意义了,可以暴力枚举单个字符的ascii值然后加密之后encrypted[i]和target[i]直接进行比较,这就解出了input[i]

云之君对sha1加密中的一些参数进行了魔改

首先是sha1_init初始化中的参数

左原始,右魔改

然后sha1_transform函数中也有魔改

sha1_transform_2

原来是m[i] = (m[i] << 1) | (m[i] >> 31);魔改为m[i] = (m[i] << 2) | (m[i] >> 30);

就这两个改动

下面就可以暴力破解了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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 <vector>
#include <algorithm>
#include "sha1.h"
using namespace std;

unsigned int target[88] = {
0x64744C9A, 0x047C2FF1, 0xA2D74292, 0x85BEF77E, 0x711FCBF7, 0x669E1609, 0x6BBD9DB6, 0x6941C8A4,
0xB16E48B3, 0xDE321186, 0x5251E8C2, 0xFB8F95A7, 0x711FCBF7, 0xCB5C3FAD, 0x36568AF5, 0xFB8F95A7,
0x82ACF96A, 0x75DCD570, 0x7EF00E40, 0xFB8F95A7, 0x4BE9314A, 0xCB5C3FAD, 0xA2D74292, 0xDE321186,
0xFB8F95A7, 0x46927FA8, 0xB16E48B3, 0xD7C1A410, 0x567375C3, 0x711FCBF7, 0xFB8F95A7, 0x9C19F0F3,
0xD035E914, 0xFB8F95A7, 0x6941C8A4, 0x0B7D1395, 0xD7C1A410, 0xC87A7C7E, 0xFB8F95A7, 0xD7C1A410,
0xDE321186, 0x5251E8C2, 0xFB8F95A7, 0xD5380C52, 0xBEA99D3B, 0xCEDB7952, 0xFB8F95A7, 0x73456320,
0xD7C1A410, 0xDE321186, 0xFB8F95A7, 0x581D99E5, 0xA2D74292, 0x711FCBF7, 0xFB8F95A7, 0x06372812,
0xFB8F95A7, 0x73456320, 0xCEDB7952, 0xEF53E254, 0xFB8F95A7, 0x9F12424D, 0x669E1609, 0xFB8F95A7,
0x9C19F0F3, 0xFECF7685, 0x0B7D1395, 0x1833E8B1, 0xFB8F95A7, 0x9F66DD04, 0xA2D74292, 0xD7C1A410,
0xFB8F95A7, 0x6941C8A4, 0x866CAF4F, 0x047C2FF1, 0x64744C9A, 0xFB8F95A7, 0xD5380C52, 0xCEDB7952,
0xDE321186, 0x81453D43, 0xCB5C3FAD, 0xB16E48B3, 0xC578F843, 0xCEDB7952, 0xDE321186, 0xE38C6F07};

int sha1(BYTE c)//接收一个char作为输入,输出加密之后的值
{
SHA1_CTX ctx;
BYTE single[12];
BYTE hash[24];

single[0] = c;
sha1_init(&ctx);
sha1_update(&ctx, single, 1);
sha1_final(&ctx, hash);
return hash[0] | (hash[1] << 8) | (hash[2] << 16) | (hash[3] << 24);
}

int main()
{
for (int t = 0; t < 88; ++t)//遍历88次
{
for (char i = 20; i < 127; ++i)//每次尝试从20到127的每个ascii值
{
if (sha1(i) == target[t])//如果对上了则打印char(i)
{
cout << i;
}
}
}

return 0;
}
1
2
3
PS C:\Users\86135\Desktop\moectfwp\MoeCTF_2022-main\Challenges\Reverse\Broken_hash> g++ main.cpp sha1.c -o main
PS C:\Users\86135\Desktop\moectfwp\MoeCTF_2022-main\Challenges\Reverse\Broken_hash> ./main
moectf{F1nd_th3_SEH_7hen_B1a5t_My_Fla9_and_Y0u_Can_Get_A_Cup_Of_Milk_Tea_From_YunZh1Jun}

但是看了flag就知道这不是预期解,flag中提到了SEH,但是我没有看到?

预期解

云之君一直说,patch程序然后写交互脚本,但是我听不懂她在说啥.看了题解才想明白为啥要这样patch程序

loc_140001DEF

把左边第7行的lea rax,dword_140005000改成lea rax,[rsp+E8h+var_C8],也就是lea rax,[rsp+20h]

然后第8行的add rax,160h改成nop

这样第9行将rax交给rdx作为参数,实际上就是将var_C8的地址放到了rdx上,然后就要调用printf函数了.var_C8是自变量i的值

这里调用printf打印i值,其目的是啥呢?

蓝色框框里是判断输入的加密是否和已经存储的密文一致,不一致则会跳转黄色框框也就是loc_140001DEF,一致则跳转红色框框报告正确

image-20221111001926390

如果进入了黄色框框,var_8C也就是循环变量是没有清零的,在黄色框框中调用printf函数打印出var_8C的值,如果var_8C=1就说明第一个输入字符对上号了,此时flag就可以确定第一个字符,然后枚举第二个字符,当var_8C打印出2的时候,flag就确定了第二个字符,以此类推

然而这里能够一个一个字符的确认就是因为sha1每次只对一个字符加密.如果sha1对整个输入同时加密,则这种方法也不能用.

也就是说,只要是"预期解"能用,"非预期解"也能用,两种解法实际上是一个道理

下面研究一下预期解的解密脚本

解密脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import subprocess

flag = 'moectf{'
for i in range(7, 88):
for j in range(0x21,0x7f):
tmp = flag + chr(j) * (88 - i)#开头是已经确定的flag,后面剩余的字符全都使用chr(j)填充,填满88个字符

p = subprocess.Popen(["Broken_hash.exe"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)#建立子进程,将其三标重定向到管道
p.stdin.write(tmp.encode())#将tmp输入到子进程 的标准输入
p.stdin.close()#关闭子进程的标准输入
out = p.stdout.read()#从子进程标准输出获取输出
p.stdout.close()#关闭标准输出

if out[-1] > i:#out[-1]是out串的最后一个字符,也就是patch程序之后,输出的匹配上的下标i,也就是var_C8,如果匹配成功,则var_C8会比i多一个,否则相等
flag += chr(j)#如果var_C*>i则j添加到flag最后
print(flag)
break

flag += '}'
print(flag)

执行之后

1
2
3
4
5
6
7
8
9
10
11
PS C:\Users\86135\Desktop\moectfwp\MoeCTF_2022-main\Challenges\Reverse\Broken_hash> python ./solve.py
moectf{F
moectf{F1
moectf{F1n
moectf{F1nd
moectf{F1nd_
moectf{F1nd_t
...
moectf{F1nd_th3_SEH_7hen_B1a5t_My_Fla9_and_Y0u_Can_Get_A_Cup_Of_Milk_Tea_From_YunZh1Ju
moectf{F1nd_th3_SEH_7hen_B1a5t_My_Fla9_and_Y0u_Can_Get_A_Cup_Of_Milk_Tea_From_YunZh1Jun
moectf{F1nd_th3_SEH_7hen_B1a5t_My_Fla9_and_Y0u_Can_Get_A_Cup_Of_Milk_Tea_From_YunZh1Jun}

subprocess模块

subprocess模块允许我们启动一个子进程,并且控制其输入输入输出

Popen() 方法

Popen 是 subprocess的核心,子进程的创建和管理都靠它处理。

构造函数:

1
2
3
4
class subprocess.Popen(args, bufsize=-1, executable=None, stdin=None, stdout=None, stderr=None, 
preexec_fn=None, close_fds=True, shell=False, cwd=None, env=None, universal_newlines=False,
startupinfo=None, creationflags=0,restore_signals=True, start_new_session=False, pass_fds=(),
*, encoding=None, errors=None)

常用参数:

  • args:shell命令,可以是字符串或者序列类型(如:list,元组)
  • bufsize:缓冲区大小。当创建标准流的管道对象时使用,默认-1。 0:不使用缓冲区 1:表示行缓冲,仅当universal_newlines=True时可用,也就是文本模式 正数:表示缓冲区大小 负数:表示使用系统默认的缓冲区大小。
  • stdin, stdout, stderr:分别表示程序的标准输入、输出、错误句柄
  • preexec_fn:只在 Unix 平台下有效,用于指定一个可执行对象(callable object),它将在子进程运行之前被调用
  • shell:如果该参数为 True,将通过操作系统的 shell 执行指定的命令。
  • cwd:用于设置子进程的当前目录。
  • env:用于指定子进程的环境变量。如果 env = None,子进程的环境变量将从父进程中继承。

wp中stdin,stderr,stdout都设置为subprocess.PIPE,这个值等于-1

意思是将标准输入输出错误都重定向到管道,然后才可以调用p.stdin.write(),还有p.stdout.read()两个函数

否则子进程的三标还是指向键盘和屏幕的,此时调用p.stdin.write()函数会报错

gogogo

go语言逆向题,没有扣符号表

很容易就能找到这么一个函数main_flagHandler

推测和flag有关系,然而这个函数的反汇编很抽象,推测是初始化的go语言的环境.

看了官方wp说关键函数是main_check

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
void __fastcall main_check(string flag, bool _r0)
{
....
if ( (unsigned int)&v24 <= *(_QWORD *)(v3 + 16LL) )
runtime_morestack_noctxt();
v27 = v2;
qmemcpy(&v23.cap, "---moeCTF2022---", 16LL);
runtime_newobject((runtime__type_0 *)flag.str, (void *)flag.len);
v26 = v4;
qmemcpy(v4, "---moeCTF2022---", 16LL);
runtime_stringtoslicebyte((runtime_tmpBuf *)flag.str, (string)__PAIR128__(v5, flag.len), v19);
v6.tab = (runtime_itab_0 *)&v23.cap;
v6.data = (void *)16LL;
main_AesEncrypt(v20, v21, v22, v23, v6);
if ( &v23 == (__uint8 *)-16LL )
{
v23.array = v27;
ptr = v7;
v23.len = 2LL * (_QWORD)v27;
runtime_makeslice(0LL, 16LL, v8, (void *)(2LL * (_QWORD)v27));
v11 = v23.array;
v12 = v23.len;
v13 = ptr;
v14 = 0LL;
v15 = 0LL;
while ( (int)v11 > v14 )
{
v16 = v13[v14];
if ( (unsigned int)v15 >= v12 )
runtime_panicIndex();
(*v15)[v9] = byte_7F65E7[v16 >> 4LL];
v10 = &(*v15)[1LL];
v17 = byte_7F65E7[v16 & 0xFLL];
if ( v12 <= (unsigned int)&(*v15)[1LL] )
runtime_panicIndex();
(*v15)[v9 + 1LL] = v17;
++v14;
v15 = (runtime_tmpBuf *)((char *)v15 + 2LL);
}
v18 = v9;
runtime_slicebytetostring(v15, v13, v12, (string)__PAIR128__((unsigned int)v10, v12));
if ( v18 == 96LL )
runtime_memequal();
}
}

看来是main_check干了这么几件事:

首先将输入使用AES加密,然后和事先存放好的密文对照

AES加密中主要需要确定(原文,密钥,偏移量,模式)等等变量

前面有两个"---moeCTF2022---"字样正好16字节,而密钥和偏移量也要求是16字节的,因此可以推测这就是密钥和偏移量

1
2
3
  qmemcpy(&v23.cap, "---moeCTF2022---", 16LL);
...
qmemcpy(v4, "---moeCTF2022---", 16LL);

如何确定模式呢?进main_AesEncrypt函数看看

1
2
3
4
5
6
7
// local variable allocation has failed, the output may be wrong!
void __fastcall main_AesEncrypt(error_0 _r1, int a2, int a3, int a4, void *a5)
...
crypto_cipher_NewCBCEncrypter(iv_8, v20, v15);
...
}
}

这有个crypto_cipher_NewCBCEncrypter函数,因此推测使用CBC模式

综上找个在线网站AES在线加密解密工具 - MKLab在线工具

解一下密

image-20221111090020054

还真就解出来了

但是总感觉这样是瞎猫碰见死耗子,推测的地方太多了,怎么合情合理地推断呢?

首先要知道程序到底干了啥?控制什么时候执行到main_flagHandler?参数都是什么?

go源代码

一看源代码,总共加起来150行左右,咱也不知道ida怎么就反编译了这么多,跟老太太裹脚一样

lr

Gin是一个用Golang编写的Web框架, 是一个基于Radix树的路由,小内存占用,没有反射,可预测的API框架,速度挺高了近40倍,支持中间件、路由组处理、JSON等多方式验证、内置了JSONXMLHTML等渲染。

也就是说server这个程序是一个go语言Gin框架搭建的应用层web服务器

main
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package main

import (
"log"
)

func main() {
if err := RouterInit(); err != nil {
log.Panic(err)
}
err := Run()
if err != nil {
log.Panic(err)
}
}

首先调用RouterInit函数,然后调用Run函数

RouterInit
1
2
3
4
5
6
7
8
9
10
11
func RouterInit() error {
gin.SetMode("release")
engine = gin.Default()
engine.GET("/welcome", welcomeHandler)
flagRouter := engine.Group("/find")
flagRouter.Use(authRequired)
flagRouter.GET("/", findHandler)
flagRouter.GET("/flag", flagHandler)
return nil
}

这里面注册了如果收到GET方法的报文并且负载是"/welcome",则调用welcomeHandler函数

image-20221111091736911

然后后面干了啥呢?

上网搜了一下,这是一个名叫"路由"的机制,推测是这么一个意思:

可以认为,engine就是访问localhost:8080之后立刻得到的页面的

然后/welcome被路由到welcomeHandler函数,执行完后再返回engine对一个你得页面

flagRouter是一个新的路由,也是engine的子路由,只要是/find路由分支,都会由engine进入flagRouter路由

然后给他注册了两个路由,如果是/find/就会转到findHandler

如果是/find/flag/就会转到flagHandler

当然这里只是注册,不会执行,实际上执行是在Run之后

Run
1
2
3
4
5
6
7
8
9
10
func Run() error {
log.Println("Service running at [127.0.0.1:8080]")
fmt.Println("Type localhost:8080/welcome in your browser and open it")
err := engine.Run("127.0.0.1:8080")
if err != nil {
return err
}
return nil
}

engine.Run就在本机的8080端口上启动HTTP服务了,此后程序一直监听该端口上的客户请求.

根据RouterInit中注册的路由,回应客户的请求,然后恢复到监听状态准备下一个客户的请求(或者一个接待线程,一个服务线程,到底怎么实现,现在不关心)

flagHandler
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func flagHandler(ctx *gin.Context) {
flag = ctx.Query("flag")//负载
if flag == "" {
ctx.JSON(400, gin.H{
"message": "please input your flag and I will check it",
})
ctx.Abort()
return
}
if !check(flag) {
ctx.JSON(200, gin.H{
"message": "flag incorrect, please try again",
})
ctx.Abort()
return
} else {
ctx.JSON(200, gin.H{
"message": "congratulations",
})
}

ctx.SetAccepted()
}

flag以GET负载的形式传递给后端

然后后端首先对flag判空,然后调用了check检查flag的正确性

check
1
2
3
4
5
6
7
8
9
10
11
12
13
func check(flag string) bool {
encFlag := "200c2c3ef00f31999df93d6919aa33e42dde307be02017ebf47067099ed0bddc525d5dba0f83c122159b89ae715907cc"
key := []byte("---moeCTF2022---")
iv := []byte("---moeCTF2022---")
encrypt, err := AesEncrypt([]byte(flag), key, iv)
if err != nil {
return false
}
if hex.EncodeToString(encrypt) == encFlag {
return true
}
return false
}

check中就是Aes加密了,加密之后的密文和encFlag这个值进行比较,如果相同则返回true,再回到flagHandler中进入else块

1
2
3
4
5
else {
ctx.JSON(200, gin.H{
"message": "congratulations",
})
}

然后就会打印恭喜发财了

看来自己写的函数,对应到符号表中的函数名都会加上main_前缀

image-20221111094105703

语法分析

算数表达式的上下文无关文法

干什么用?

教材中在语法分析一章首先提出的概念就是上下文无关语法CFG

语法分析器不能没有CFG,正如词法分析器不能没有正规式

也就是说,模式的形式化描述

正规式规定死了,一种语言可能使用的单词有什么

CFG就是规定死了,一种语言可能使用的语句有什么

CFG是一种语言的范式,而满足这个CFG的一句话是该种语言的实例

这就好比:规定汉语的语句是由主谓宾构成的,这相当于立法,就是定义CFG

"我吃饭"就是满足法律要求的:主(我) 谓(吃) 宾(饭),这就是汉语语句的一个实例

判断"我吃饭"是否合法的过程,就是将一条语句展开成多个部分的过程,

判断编程语言的一条语句是否合法,就尝试使用CFG规定的规则推导之

长什么样?

以基本算数运算为例子,分析一个含有加减乘除括号的表达式,其CFG如何定义

CFG由一个状态四元组(非终结符集,终结符集,产生式集,开始符号)构成

最初粗浅的考虑是这样的 $$ \[\begin{aligned} Context Free Grammar&=(Nonterminals,Terminals,Productions,Start Symbol)\\ &\begin{cases} Nonterminals=\{Expression\}\\ Terminals=\{+,\times,(,),id\}\\ StartSymbol=Expression\\ \begin{aligned} Production&:Expression\rightarrow\begin{cases} Expression+ Expression\\ Expression\times Expression\\ (Expression)\\ -Expression\\ id \end{cases} \end{aligned} \end{cases} \end{aligned}\]

$$ ​ Nonterminals集合中只有Expression,意思是,只要是Expresion符号,都可以继续推导

​ Terminals集合中有所有的运算符,还有一个字面量id,这意味着,推导到最具体的了,不能再具体了

​ StartSymbol=Expression,这意味着,最开始的时候将整个语句视为一个表达式

怎么干活?

"通过推导的方法可以从CFG产生语言"

实际上就是根据Production产生式集中的合法规则,将抽象的Expression全都替换成具体的id的过程

啥意思呢?举个例子,比如对于1+2+3,一开始的时候StartSymbol="1+2+3",这个整体是一个表达式Expression \[ \begin{aligned} exp(1+2+3)&\rightarrow exp(1)+exp(2+3)\\ &\rightarrow id(1)+exp(2)+exp(4)\\ &\rightarrow id(1)+id(2)+id(3) \end{aligned} \] 这就是左推导的过程,也就是化抽象为具体的过程

严谨吗?

前面这种定义CFG的方法是不严谨的,这么说的原因有两个,

一是是没有体现运算符的优先级,二是没有体现运算符的结合性

优先级

举例1+2*3

按照原来的CFG定义,\(exp(1+2)\times exp(3)\)\(exp(1)+exp(2\times 3)\)两种推导路线都是合法的,这就产生了二义性

结合性

举例1+2+3

按照原来的CFG定义,\(exp(1+2+3)\rightarrow exp(1+2)+exp(3)\)\(exp(1+2+3)\rightarrow exp(1)+exp(2+3)\)都是合法的,这就产生了二义性

消除二义性

如何消除二义性?保证每个表达式后续的推导,只有唯一的路线 $$ \[\begin{aligned} Context Free Grammar&=(Nonterminals,Terminals,Productions,Start Symbol)\\ &\begin{cases} Nonterminals=\{Expression,Term,Factor\}\\ Terminals=\{+,\times,(,),id\}\\ StartSymbol=Expression\\ \begin{aligned} Production\begin{cases} \begin{aligned} Expression&\rightarrow Expression+Term\ |\ Term\\ Term&\rightarrow Term\times Factor\ | \ Factor\\ Factor&\rightarrow (Expression)\ |\ -Factor\ |\ id \end{aligned} \end{cases} \end{aligned} \end{cases} \end{aligned}\]

$$ 如何体现优先级?

\(Expression\)全部展开之后得到的是\(Term1+Term2+Term3+...\)只有这一种情况,

也就是说,将Expression视为若干乘式的和,每个乘式再各自计算自己的值后上报给加法.

想要计算加法就必须等所有的乘式落实之后才可以进行.这就体现了先乘后加的优先级

如何体现结合性?

\(Expression\rightarrow Expression+Term\ | \ Term\)

这意味着,要计算顶层的加法,首先需要计算子Expression的值,

而子Expression相当于是,顶层Expression将最右侧的\(+Term\)给踢出去.

这就体现了左结合

教材上将上述两点归纳:

1.引入新的非终结符,增加一个子结构并提高一级优先级

2.递归非终结符在产生式中的位置,反应文法符号的结合性

这个归纳纯粹属于是懂得一看就懂,不懂得看了也不懂的那种

甚至"递归非终结符在产生式中的位置"啥意思我也不懂

这是在算数表达式中消除二义性,

实际上在编程语言中还要考虑一个分支结构的特殊性

if-elif-else结构中的匹配原则,就近还是就远原则等等

这是后话

自上而下语法分析

树形结构?

由CFG可以建立两种长得差不多的树形结构,

分析树和语法树,

或者说具体语法树和抽象语法树

1+2*3为例,推导该表达式的过程为 \[ \begin{aligned} exp(1+2\times 3)&\rightarrow exp(1)+term(2\times 3)\\ &\rightarrow term(1)+term(2)\times fact(3)\\ &\rightarrow fact(1)+fact(2)\times id(3)\\ &\rightarrow id(1)+id(3)\times id(3) \end{aligned} \]

分析树(具体语法树)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
flowchart
PLUS1((+))
MULT((*))
EXP123(("exp(1+2*3)"))
EXP1(("exp(1)"))
TERM23(("term(2*3)"))
TERM1(("term(1)"))
TERM2(("term(2)"))
FACT3(("fact(3)"))
FACT1(("fact(1)"))
FACT2(("fact(2)"))
ID3(("id(3)"))
ID1(("id(1)"))
ID2(("id(2)"))

EXP123----EXP1
EXP123----PLUS1
EXP123----TERM23
EXP1----TERM1
TERM1----FACT1
FACT1----ID1

TERM23----TERM2----FACT2----ID2
TERM23----MULT
TERM23----FACT3----ID3

语法树(抽象语法树)

1
2
3
4
5
6
7
8
9
10
graph
1((1))
2((2))
3((3))
mul((*))
plus((+))
plus----1
plus----mul
mul----2
mul----3

两种树的区别

抽象语法树中,叶子节点都是字面量,不能再推导,内部节点肯定都是运算符.

求值时当前节点只需要根据自己的运算规则,取出左右儿子递归运算值,算一下然后交给父节点

咱也不知道这具体语法树有什么作用,明明抽象语法树简洁清晰并且也能完成功能

状态转移图和EBNF

一眼丁真

状态转移图实际上就是为了形式化地得到EBNF,要是能够从本来的文法直接看出EBNF文法,则不需要状态转移图

对于表达式这种简单的文法,直接就能看出EBNF来 \[ \begin{aligned} Expression&\rightarrow Term\ \{(+|-)Term\}\\ Term&\rightarrow Factor\ \{(\times |/) Factor\}\\ Factor&\rightarrow (Expression)\ |\ id\ |num \end{aligned} \] ​ 其中

花括号{}中的内容可以重复0次或者若干次,相当于星闭包

怎么理解文法的EBNF表示呢? \[ \begin{aligned} Expression &\rightarrow Term\ \{(+|-)Term\}\\ &\rightarrow Term±Term±Term±...±Term \end{aligned} \] 也就是说Expression至少由一个Term组成,还可以是若干个Term的加减组成

一眼看不出来咋办?

现在已经知道的是文法规则 \[ \begin{aligned} Language&\rightarrow Expression;Language|\epsilon\\ Expression&\rightarrow Term\ Expression'\\ Expression'&\rightarrow +Term\ Expression'|\epsilon\\ Term&\rightarrow Factor \ Term'\\ Term'&\rightarrow \times Factor\ Term'|\epsilon\\ Factor&\rightarrow (Expression)|-Factor|id \end{aligned} \]

要求出文法的EBNF表示.

眼神不好,不能一眼丁真,就得借助状态转移图

怎么由原来的文法翻译成状态转移图?

以Language为例子:

1
2
3
4
5
6
7
8
flowchart LR
0((0))
1((1))
2((2))
3((3))
Language ---->0--Expression-->1--";"-->2--Language-->3
0--ε-->3
style 3 fill:red

推导式中的所有符号,比如Language,Expression等,都作为有向图上的边,代表状态转移.状态作为节点

显然目前建立的所有有向图都是没有圈的,因为目前的文法定义已经消除了所有直接或者间接的左递归,表现到图上就是没有强连通分量

又如对于Expression和Expression'

1
2
3
4
5
flowchart LR
4((4))
5((5))
6((6))
Expression-->4--Term-->5--"Expression'"-->6
1
2
3
4
5
6
7
flowchart LR
7((7))
8((8))
9((9))
10((10))
Expression'-->7--+-->8--Term-->9--Expression'-->10
7--ε-->10

下面就是化简状态转移图,怎么化简?

对于Expression',有一个规则1:

标记为A的边可等价为标记ε的边转向A转换图的初态;

1
2
3
4
5
6
7
8
9
flowchart LR
7((7))
8((8))
9((9))
10((10))
7--ε-->10
Expression'-->7--+-->8--Term-->9
9--ε-->7
style 10 fill:red

然后紧接着可以应用规则2:

ε两头的节点可以合并,也就是说,epsilon闭包中的节点算成一个点

1
2
3
4
5
6
7
flowchart LR
7((7))
8((8))

Expression'-->7--+-->8--Term-->7

style 7 fill:red

然后紧接着可以应用规则3:

标记相同的路径可以合并

啥意思呢?Expression'现在可以带入到Expression中了

1
2
3
4
5
6
7
8
9
flowchart LR
4((4))

7((7))
8((8))

7--+-->8--Term-->7
Expression-->4--Term-->7
style 7 fill:red

还有一条规则可以继续化简:

不可区分的状态可以合并

这里4状态和8状态都是经过Term转移到7,不可区分,因此有

1
2
3
4
5
6
7
8
9
flowchart LR
4((4))

7((7))


7--+-->4
Expression-->4--Term-->7
style 7 fill:red

此时Expression'已经被扬了,Expression也是不能再简了,这就是最简结构了.根据这个结构,终于一眼丁真了 \[ Expression\rightarrow Term\{+Term\} \]

这里只考虑了加法,减法和加法同级,也容易扩充 \[ Expression\rightarrow Term\{(+|-)Term\} \]

以此类推,可以得到所有符号的EBNF文法 \[ \begin{aligned} Language&\rightarrow \{Expression;\}\\ Expression&\rightarrow Term\{(+|-)Term\}\\ Term&\rightarrow Factor\{(\times |/)Factor\}\\ Factor&\rightarrow (Expression)|id|num \end{aligned} \] 得到该EBNF表示的文法之后,已经可以动手写程序了.

硬编码的代码实现

表达式求值

贴出来又臭又长,不如放到pasbin了递归下降求解表达式 - Pastebin.com

表达式建立语法树

源代码中有反引号,并且博客渲染的时候代码块会匹配最近的反引号,这应该是语法分析的错误罢

发生代码块反引号匹配错误,只能放到pastbin里了表达式树 - Pastebin.com

将树的结构按照mermaid语法规则输出到tree.md文件,然后用typora打开就可以实现可视化

visiable

预测分析

为啥还需要预测分析?

上面我们已经写出代码了,表达式的语法分析可以说已经竣工了,但是为啥本章后面教材还是不依不饶地给了60页?

感觉教材也没有给预测分析器做一个铺垫,直接就抛出了这么一个概念干说.

我觉得,这是因为从EBNF构建语法分析器有很大的局限性,类比一下

根据EBNF写语法分析器,就类似于词法分析器用硬编码的DFA

为什么这样说呢?或者说根据EBNF写代码有啥缺点呢?

首先,EBNF中规定了死了,基本算数运算只有三种优先级,这对应到代码中的三个函数

1
2
3
evalExpression
evalTerm
evalFactor

试想,如果写完程序之后,又想加入新的优先级,比如规定乘方运算的优先级高于乘除低于括号.

又如方括号优先级低于小括号但是高于其他表达式.

这就意味着程序需要大改,新加入evalPower来处理乘方运算,并且函数的调用关系都得改

这就是从EBNF直接写代码的局限性,也就是说可拓展性差

怎么解决呢?还是回顾词法分析中的做法

当时我们也是嫌硬编码的DFA不灵活,于是使用了表驱动或者有向图驱动的DFA.

DFA对外提供三个接口,都屏蔽了自身实现的差异.

表驱动的DFA想要拓展只需要新增表记录就可以了

这就是预测分析器预测分析表将要干的事情

预测分析器

类比词法分析

前面我们已经给预测分析器做好铺垫了

预测分析器是词法分析器的一种实现方式,是下推自动机的一种实现

预测分析器是下推自动机的实例

ip指向当前输入

这个预测分析器和使用表驱动DFA的词法分析器有一定的相似之处,可以类比理解一下

左表驱动DFA词法分析,右预测分析语法分析

词法分析器上的状态转移,只需要看一下输入序列,然后去分析表查一下下一个状态就可以了

相比于词法分析器,语法分析器多一个符号栈,也就是说预测分析器上的状态转移,不仅要考虑当前记号流中的记号,还要考虑当前符号栈的状态

没有递归?

同时,从这个类比中,我们也可以隐约发现,预测分析器是没有递归的

之前根据EBNF写的代码,递归很明显,evalExpression->evalTerm->evalFactor

而现在只需要从预测分析表上进行状态转移.消除了递归

这意味着,现在可以将递归的方法,从表达式推广到绘图语言,写"编译原理的实验二--语法分析器"了,

实际上WXQ老师给出了2019C++实现版也就是基于递归下降方法实现的

预测分析表是干啥的?

首先考虑这么一个表达式\(id+id\times id\),如果使用前面定义的文法进行匹配,手工做的话应该如何

\[ \begin{aligned} Language&\rightarrow Expression;Language|\epsilon\\ Expression&\rightarrow Term\ Expression'\\ Expression'&\rightarrow +Term\ Expression'|\epsilon\\ Term&\rightarrow Factor \ Term'\\ Term'&\rightarrow \times Factor\ Term'|\epsilon\\ Factor&\rightarrow (Expression)|-Factor|id \end{aligned} \]

\[ \begin{aligned} L&\rightarrow E\\ &\rightarrow TE'\\ &\rightarrow FT'E'\\ &\rightarrow idT'E'\\ &\rightarrow idE'\\ &\rightarrow id+TE'\\ &\rightarrow id+FT'E'\\ &\rightarrow id+idT'E'\\ &\rightarrow id+id\times FT'E'\\ &\rightarrow id+id\times idT'E'\\ &\rightarrow id+id\times id \end{aligned} \]

我们这样做的依据就是文法规则中定义好的推导方式

预测分析表就干了我们脑子干的事情----匹配使用哪个推导规则

先给出这么一个状态转移表,简单地用它分析一下id+id*id,后来再关心这个表是怎么建立的

image-20221109212543264

比如当前栈顶是L,输入符号是id,那么就应该首先L弹栈,然后把符号E压栈,转移到下一个格局

当前栈顶是E,输入符号id,就应该首先E弹栈,然后把TE'压栈,转移到下一个格局

当前栈顶是T,输入id,就应该首先T弹栈,然后FT'压栈,转移到下一个格局

当前栈顶是F,输入id,就应该首先F弹栈,然后id压栈,转移到下一个格局

当前栈顶是id,输入id,匹配成功,都退栈

整个过程中"输入id"一直没变,也就是说指向输入串的指针始终没有移动

这里有一个新名词"格局",实际上类似于状态,它受到当前栈顶,当前输入,预测分析表三者的影响

实际上就是迪杰斯特拉双栈算法求解算数表达式的算法思想

不断压栈直到栈顶和当前符号匹配实际上维护了一个单调栈,越靠近栈顶的符号优先级越高

如何定义格局?

表驱动的词法分析器中定义了一个状态,然后根据当前输入和状态转移表这里两个因素,从当前状态转移到下一个状态

对于预测分析表驱动的语法分析器,定义了一个类似状态的东西叫做"格局",

区别于状态转移,格局转换时需要考虑当前栈顶,预测分析表,当前输入符号,共3个因素

实际上格局变化是状态转移的推广

状态转移时维护一个栈当摆设就成了格局变化

格局如何转移?

1
2
3
4
5
6
7
8
9
10
11
12
13
设置ip使它指向w的第一个符号,其中ip 是输入指针;
令X=栈顶符号;
while ( X ≠ $ ){ / * 栈非空 */
if ( X 等于ip所指向的符号a) 执行栈的弹出操作,将ip向前移动一个位置;
else if ( X是一个终结符号) error ( ) ;
else if ( M[X,a]是一个报错条目) error ( ) ;
else if ( M[X,a] = X →Y1Y2 … Yk ){
输出产生式 X →Y1Y2 … Yk ;
弹出栈顶符号;
将Yk,Yk-1 …,Yi 压入栈中,其中Y1位于栈顶。
}
令X=栈顶符号
}

这里面M[X,a]就是当前栈顶为X,当前符号为a时查表能够转移到的下一个格局

匹配id+id*id的整个过程如图

image-20221109215743134

语法分析阶段不管是递归下降还是预测分析,其目的相同

1.首先检查有没有语法错误.

2.给出抽象语法树.

递归下降法自顶向下建立抽象语法树是自然而然的

预测分析法怎么建立抽象语法树呢?

一时间竟想不到

蚌埠住了

如何建立预测分析表?

预测分析器算法和文法定义没有关系,文法定义体现在预测分析表上

预测分析器的算法是写死的,但是预测分析表是活的

First集合

\(First(\alpha)=\{a|\alpha\Rightarrow a,a\in T\}\)也就是\(\alpha\)可以推导出的一个非终结字符的集合,如果\(\alpha\Rightarrow \epsilon\),则\(\epsilon \in First(\alpha)\)

\(First(E)=\{(,id,num\}\)

Follow集合

\[ Follow(A)=\{a|S\Rightarrow ...Aa...,a\in T\} \]

紧跟在非终结符A后面的终结符集合

\(Follow(E)=\{;,)\}\)

算法求解First集合

\[ X\rightarrow \alpha_1|\alpha_2|...|\alpha_m,m\ge1\\First(X)=\cup First(\alpha_i) \]

非终结符的First集合,等于其所有推导的First集合的并集

对于\(First(\alpha_i)\)的求法:

其中如果\(\alpha_i=\epsilon\),则\(\epsilon\in First(\alpha_i)\)

否则,假设\(\alpha_i\Rightarrow Y_1,Y_2,...,Y_n\)

如果\(Y1\)可以推导出\(\epsilon\),则Y2就有可能成为最前面的,那么就得考虑\(First(Y_2)\)

如果\(Y_1\)推导不出\(\epsilon\),则Y2就肯定不可能成为最前面,因此不用考虑\(First(Y_2)\) \[ First(\alpha_i)=\cup _{j=1}^k (First(Y_j)-\epsilon) \]

\(Y_k\)推导不出\(\epsilon\)时,才会停止j往后遍历

求解Follow集合

算数表达式的预测分析语法分析

\[ \begin{aligned} Language&\rightarrow Expression;Language|\epsilon\\ Expression&\rightarrow Term\ Expression'\\ Expression'&\rightarrow +Term\ Expression'|\epsilon\\ Term&\rightarrow Factor \ Term'\\ Term'&\rightarrow \times Factor\ Term'|\epsilon\\ Factor&\rightarrow (Expression)|-Factor|id \end{aligned} \]