0%

最近一直在学习,但是却忽略了最基本的需求——想要有所反馈,所以的话找了几个项目来做一做,这里的话我挑选的是一个用C语言实现shell的项目,用的是Linux编程,刚好我还没有试过在WSL上的编程开发,所以试一试呀,顺便把这个英语博客翻译一遍,锻炼下水平。

项目地址:[brenns10/lsh: Simple shell implementation. Tutorial here ->]

LinuxShell

Shell 的生命周期

Shell 在其生命周期中主要有以下三个动作:

  • 初始化:

    在这一步中,一个典型的Shell会读取并执行它的配置文件,从而引导shell的行为。

  • 解释:

    接着shell会从标准输入(stdin)中读取并执行你的输入(这里的输入还可以是文件等内容)

  • 终端:

    当程序被执行之后,Shell需要负责关闭程序,释放内存,并将其终止

这几个步骤在许多程序开发中都是适用的,但是我们将使用他们作为我们的Shell程序的基础。我们的程序十分简单,不需要任何配置文件,也不需要任何关闭命令。实际上,我们只需要循环调用并终止它们。当然在我们的程序架构中,循环并不是最为重要的。

1
2
3
4
5
6
7
8
9
10
11
int main(int argc,char ** argv){
//加载配置文件(如果有的话)

//执行循环指令
lsh_loop();

//关闭清理操作

return EXIT_SUCCESS;

}

这里你可以看到实际上就一个函数,它将不停的循环解释命令。我们接下来来完成lsh_loop()的实现

Shell中的基础循环

我们已经考虑完了程序的启动。现在,我们需要搞清楚程序的逻辑,在一次又一次的循环中,我们需要做什么?实际上,我们只需要实现三个步骤以处理这些命令:

  • 读取:从标准输入中读取命令
  • 解析:分割命令,将其分为程序与参数
  • 运行:运行解析后的命令

接下来,让我们一点点实现它:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void lsh_loop(){
char *line;
char **args;
int status;

do {
printf("> ");
line = lsh_read_line();
args = lsh_spilt_line(line);
status = lsh_execute(args);

free(line);
free(args);
} while (status);
}

我们看这个程序,前几行是几个变量的声明。这个DO-WHILE循环则是用来方便用来对status变量的检查,因为在每次循环后都会检查一遍它的值。在循环中,我们打印一个提示符,然后读取一行,使用函数来将其划分为关键词,然后再去执行他们。最终我们释放读取的行字符和我们创建的参数变量。在这里我们利用lsh_execute()返回的的status来决定何时退出循环。

行读取

从标准输入中读取一行看起来很容易,但是在C里面实现起来很困难1,因为你并不知道用户会往里面输入多长的内容。你不能只是简单的分配一个内存块,然后祈祷用户的输入不会超出内存块的大小。相反,你需要从一个内存块开始,如果超出了它们,就再次分配一个空间给他们。这是在C中常用的一种策略,我们在下面实现它:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
char *lsh_read_line(){
int bufsize = LSH_RL_BUFSIZE;
int position = 0;
char *buffer = malloc(sizeof(char) * bufsize);
int c;

if(!buffer){
fprintf(stderr,"lsh: allocation error\n");
exit(EXIT_FAILURE);
}

while (1){
//读取字符
c = getchar();

//确定结束条件
if(c == EOF || c == '\n'){
buffer[position] = '\0';
return buffer;
}else{
buffer[position] = c;
}
position++;

//如果超出缓冲区,则拓展新的缓冲区
if (position >= bufsize){
bufsize += LSH_RL_BUFSIZE;
buffer = realloc(buffer,bufsize);
if(!buffer){
fprintf(stderr,"lsh: allocation error\n");
exit(EXIT_FAILURE);
}
}
}
}

前面是一些声明。不过你不用太过在意他们,我更喜欢使用旧的C语言风格——在代码块的前面声明变量。而这个函数的核心则是在循环部分。在循环中我们读取字符(我们将其存储为整数型,而不是一个字符,在比较EOF时,是整型的比较2这是C语言新手经常会犯的错误),如果是回车或者EOF,我们终止并返回它,反之则继续添加字符。

接着,我们需要知道接下来的字符会不会超出缓冲区的大小。如果会的话我们就重新申请一块空间,然后再继续(要注意是否声明出错哦),这样就解决了。

熟悉最新的C语言标准的读者可能注意到,我们写的这个程序实际上已经有实现的函数了getline()。这个函数是GNU对C语言库的拓展。用它实现会更加的容易,不过上面的程序能帮你更好的理解这个函数的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
char *lsh_read_line(){
char *line = NULL;
ssize_t bufsize = 0;
if (getline(&line,&buffer,stdin) == -1){
if (feof(stdin)){
exit(EXIT_SUCCESS); //读取到了EOF
}else{
perror("readline");
exit(EXIT_FAILURE);
}
}
return line;
}

解析行

OK我们将目光放回循环中,我们已经实现了对它的读取,现在我们需要将其解释为一个参数列表。我们尽可能的简化它,所以在这里我们不支持引号和反斜杠转义在我们的命令行参数中。相反,我们将简单的使用空格来将参数一一分隔开来。所以命令echo "this message"并不会打印出this message,而是打印出thismessage

通过这些简化,我们用空格将他们划分为token,我们可以用标准库中的strtok函数实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#define LSH_TOK_BUFSIZE 64
#define LSH_TOK_DELIM " \t\r\n\a"

char **lsh_split_line(char *line){
int bufsize = LSH_TOK_BUFSIZE,position = 0;
char **tokens = malloc(bufsize * sizeof(char*));
char *token;

if(!token){
fprintf(stderr,"lsh: allocation error\n");
exit(EXIT_FAILURE);
}

token = strtok(line,LSH_TOK_DELIM);
while(token != NULL){
tokens[position] = token;
position++;

if (position >= bufsize){
bufsize += LSH_TOK_BUFSIZE;
tokens = realloc(tokens,bufsize * sizeof(char*));
if(!tokens){
fprintf(stderr,"lsh: allocation error\n");
exit(EXIT_FAILURE);
}
}

token = strtok(NULL,LSH_TOK_DELIM);

}
tokens[position] = NULL;
return tokens;
}

也许你会发现这些代码长得很像,当然如此!我们使用相同的策略实现缓冲区的动态分配,不过这一次我们使用的是以NULL结尾的指针数组,而不是以’\0’结尾的字符数组

在函数的开始,通过对strtok()的调用,我们返回第一个标记的地址,实际上返回的是你给的字符串的地址。且用’\0’替换分隔符号,接着将每一个指针存放在指向字符的指针数组中

如果有必要的话,我们会重新分配指针数组的大小,直到没有token返回,此时终止读取

当我们得到了这些token和存储它的数组,我们要准备解析它了。接下来面临一个问题,我们该怎么做呢?

Shell如何启动进程

现在,我们已经来到了Shell的核心问题——启动进程。写一个Shell程序意味着你需要知道一个进程做了什么,以及怎么去启动它。接下来我们将简单的讨论一下类Unix系统中的进程

在Unix中只有两种方式去启动一个进程。第一种(几乎不算)是Init,当Unix系统启动时,它的内核被加载。当它的内核被加载并初始化时,内核将启动一个进程,即Init。这个进程一直运行在计算机的运行过程中,它负责加载那些其他的计算机所要用到的进程。

由于大多数进程并不是Init,所以实际上只剩下另一种启动进程的方法:系统调用。当调用一个函数时,操作系统复制进程并将他们运行。其中原来的进程被称之为“父进程”,新的进程被称之为“子进程”。将0返回给子进程并将子进程的PID返回给父进程。实际上,这意味着启动新进程的方式就是复制现有的进程。(fork)

这面临着一些问题。典型的,当你想要运行一个新的进程,你并不仅仅想要运行一个一样的进程,而是运行一个不一样的程序。这就是系统调用的意义所在。它用一个全新的程序替换当前正在运行的程序,这意味着当你调用时,操作系统将停止你的进程,加载新程序,并在其位置启该程序。进程不会从调用中返回(除非它发生了错误)(exec)

通过着两种系统调用,我们有了大多数程序在Unixs上运行的基本要素。首先一个现有的进程分成两个单独的部分。然后子进程将被替换成一个新的进程,父进程可以继续做其他的事情,甚至可以通过系统调用继续对子进程进行访问。

讲了这么多,现在,可以终于可以尝试编写一段启动代码了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
int lsh_launch(char **args){
pid_t pid;
int status;

pid = fork();
if(pid == 0){
//子进程
if(execvp(args[0],args) == -1){
perror("lsh");
}
exit(EXIT_FAILURE);
}else if(pid < 0){
//错误的进程
perror("lsh");
}else{
//父进程
do{
wpid = waitpid(pid,&status,WUNTRACED);
}while (!WIFEXITED(status) && !WIFSIGNALED(status));
}
return 1;
}

现在这个函数将使用我们先前解析的参数列表。然后分叉这个进程,并保存返回值(PID)。当fork()成功返回,此时我们有两个并发的进程。子进程将进入判断逻辑的第一种情况。

在子程序中我们希望能够运行用户的命令。所以我们使用exec系统调用的变种之一execvp。和exec的其他几个变种实现的效果有略微的不同。有的变种需要可变长度的字符串参数,有的则需要字符串列表,还有的需要指定进程使用的环境。而execvp()需要一个程序名称和一个字符串参数的列表(第一个参数必须是程序的名称,而不应该是程序的文件路径),当我们将程序名称传递给它,操作系统将在系统文件路径中去查询它

如果解析命令返回了-1(实际上可能返回其他的),那么说明产生了错误。此时我们使用perror打印系统的错误信息,前面加上程序的名称,以便于用户知道错误产生于哪里。然后我们退出当前进程以确保程序能继续运行

第二种判断情况说明fork()产生了错误。如果确实如此我们向用户返回错误信息,并确认是否需要终止。

第三种判断情况意味着fork()的子进程创建很成功。此时父进程将待命,我们知道子进程正在运行当前进程,因此父进程需要等待命令的完成。我们使用waitpid()等待进程的状态改变。不过进程的改变可能是由于各种原因,不仅仅只是因为进程终止了,进程可能是因为正常退出了,也可能是因为错误的代码,或是因为信号的终止。所以我们使用waitpid()提供的宏定义等待程序的退出或终止。当函数最终返回1,这意味着我们继续读取命令并执行了

内置函数

你也许会注意到lsh_loop()调用了lsh_execute(),但是在上面,我却又命名了一个函数lsh_launch,这是故意的。你看,大多数的命令在Shell中被解析为程序,但并不是所有,有一些是直接写入Shell中的

原因很简单。如果你想要更改当前所在的目录。你需要使用函数chdir(),问题是文件目录是当前进程的一个属性,所以如果你要使用cd改变当前目录的话,它将改变你当前进程的文件目录,父进程的目录将会保持不变。相反的是,Shell进程本身需要调用chdir()来更新它的目录。然后再启动子进程,它将继承当前的文件目录

相似的,exit程序,它将无法退出调用它的Shell程序,所以这个程序也需要内置在Shell中。当然,许多Shell程序都是通过配置运行配置文件进行的,比如~/.bashrc这些脚本使用更改Shell操作的命令。不过这些命令只有在 shell 进程本身内部实现时才能更改 shell 的操作。

所以这意味着我们需要添加一些Shell本身的命令。这里我们添加cd,exithelp程序,以下是它的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
//内置函数
int lsh_cd(char **args);
int lsh_help(char **args);
int lsh_exit(char **args);
//内置指令
char *builtin_str[] = {
"cd",
"help",
"exit"
};
//函数指针
int (*builtin_func[])(char **)={
&lsh_cd,
&lsh_help,
&lsh_exit
};

int lsh_num_builtins(){
return sizeof(builtin_str) / sizeof(char *);
}

int lsh_cd(char **args){
if(args[1] == NULL){
fprintf(stderr,"lsh: expected argument to \"cd\"\n");
}else{
if(chdir(args[1]) != 0){
perror("lsh");
}
}
return 1;
}

int lsh_help(char **args){
int i;
printf("Stephen Brennan's LSH\n");
printf("Type program names and arguments,and hit enter.\n");
printf("The following are built in:\n");

for(i=0;i<lsh_num_builtins();i++){
printf(" %s\n",builtin_str[i]);
}

printf("Use the man command for information on other programs.\n");
return 1;
}

int lsh_exit(char **args){
return 0;
}

这一部分的代码有三个部分,第一部分包含了对函数的声明。提供函数原型是为了你可以使用它(即不定义函数内容,但声明后可以使用)。这是因为我们我们使用lsh_help()时,用到了内置函数数组,这个函数中包含了lsp_help(),如果想要打破这个循环则需要一开始就提供函数原型。

第二部分则是一个字符串数组,存储了函数的名称。后面跟了对应的函数指针的数组。这样将来就可以通过编辑函数数组来实现向其中添加功能,而且可以避免编写大型的switch程序。如果你对builtin_func的作用有所疑惑的话,那没问题!我也是。这是一个函数指针数组(它们都需要提供一个字符串数组,然后返回一个整数型的数值)任何涉及C语言中函数指针的声明都会变得十分复杂。我每次都要搜一下怎么声明3

最终,我们实现了这几个函数功能。lsh_cd函数首先检查第二个参数是否存在,如果不存在的话就打印错误信息。然后调用chdir()检查是否有误并返回。而lsp_help函数则是打印了作品的信息,还有内置的函数功能。最后lsp_exit函数返回0,作为终止循环的信号

分析

最后的一部分就是实现lsh_execute(),这个函数将用来调用内置函数,或是用来启动Shell进程,如果你读到了这里,你就知道我们为自己设置了多么简单的功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int lsh_execute(char **args){
int i;
if(args[0] == NULL){
//空指令,直接返回
return 1;
}
for(i=0;i<lsh_num_builtins();i++){
if(strcmp(args[0],builtin_str[i]) == 0)
return (*builtin_func[i])(args);
}

return lsh_launch(args);
}

这些操作实际上是为了检查你输入的命令是否等于内置函数,如果是的话,就调用它,如果不是的话,将会调用lsh_launch()去启动这个进程。如果用户输入了一个空字符串,则会被警告参数为NULL。所以我们需要检查输入

整合

以上就是Shell所有的程序了。如果你耐心的读完了,那么你已经完全理解了Shell是怎么实现的了,快去试试吧。你只需要将这些代码复制到一个文件中,然后编译它。将你需要的文件头包含在里面。接下来我将提供我们从文件头中所用到的函数:

  • #include <sys/wait.h>
    • waitpid() and its macros
  • #include <unistd.h>
    • chdir()
    • fork()
    • exec()
    • pid_t
  • #include <stdlib.h>
    • malloc()
    • realloc()
    • free()
    • exit()
    • execvp()
    • EXIT_SUCCESS,EXIT_FAILURE
  • #include <stdio.h>
    • fprintf()
    • pintf()
    • stderr
    • getchar()
    • perror()
  • #include <string.h>
    • strcmp()
    • strtok()

你可以在开头的链接找到这篇文章的出处和源代码。


翻译加复现和学习差不多花了两天,感觉很有收获,很多地方翻译的不是很好,但是大致能理解。就我个人的感受而言,我感觉看英文的话勉强可以看懂是啥意思,但是翻译成中文的话总是不能很好的表达出来,可能这也和专业水平有关吧(加上我的英语不是很好)。我觉得这是一篇很好的文章,尤其是其中关于进程的部分让我学到了很多东西。我自己也跟着复现了一遍,为此我还配置了我的neovim(真的好帅),不过由于语法检测没有配置好,所以最后编译的时候还是一堆问题,磕磕绊绊的,但是还是让它成功的跑起来了,很有成就感!

最后附上我完整的程序(作者的信息我并没有修改,毕竟是一次复现)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

#define LSH_RL_BUFSIZE 1024
#define LSH_TOK_BUFSIZE 64
#define LSH_TOK_DELIM " \t\r\n\a"

//内置函数
int lsh_cd(char **args);
int lsh_help(char **args);
int lsh_exit(char **args);
//内置指令
char *builtin_str[] = {
"cd",
"help",
"exit"
};
//函数指针
int (*builtin_func[])(char **)={
&lsh_cd,
&lsh_help,
&lsh_exit
};

void lsh_loop();
char *lsh_read_line();
char **lsh_split_line(char *line);
int lsh_launch(char **args);
int lsh_execute(char **args);
int lsh_num_builtins();

int main(int argc,char ** argv){
//加载配置文件(如果有的话)

//执行循环指令
lsh_loop();

//关闭清理操作

return EXIT_SUCCESS;
}

void lsh_loop(){
char *line;
char **args;
int status;

do {
printf("> ");
line = lsh_read_line();
args = lsh_split_line(line);
status = lsh_execute(args);

free(line);
free(args);
} while (status);
}

char *lsh_read_line(){
int bufsize = LSH_RL_BUFSIZE;
int position = 0;
char *buffer = malloc(sizeof(char) * bufsize);
int c;

if(!buffer){
fprintf(stderr,"lsh: allocation error\n");
exit(EXIT_FAILURE);
}

while (1){
//读取字符
c = getchar();

//确定结束条件
if(c == EOF || c == '\n'){
buffer[position] = '\0';
return buffer;
}else{
buffer[position] = c;
}
position++;

//如果超出缓冲区,则拓展新的缓冲区
if (position >= bufsize){
bufsize += LSH_RL_BUFSIZE;
buffer = realloc(buffer,bufsize);
if(!buffer){
fprintf(stderr,"lsh: allocation error\n");
exit(EXIT_FAILURE);
}
}
}
}

/*
//getline实现行读取
char *lsh_read_line(){
char *line = NULL;
ssize_t bufsize = 0;
if (getline(&line,&buffer,stdin) == -1){
if (feof(stdin)){
exit(EXIT_SUCCESS); //读取到了EOF
}else{
perror("readline");
exit(EXIT_FAILURE);
}
}
return line;
}
*/

char **lsh_split_line(char *line){
int bufsize = LSH_TOK_BUFSIZE,position = 0;
char **tokens = malloc(bufsize * sizeof(char*));
char *token;

if(!token){
fprintf(stderr,"lsh: allocation error\n");
exit(EXIT_FAILURE);
}

token = strtok(line,LSH_TOK_DELIM);
while(token != NULL){
tokens[position] = token;
position++;

if (position >= bufsize){
bufsize += LSH_TOK_BUFSIZE;
tokens = realloc(tokens,bufsize * sizeof(char*));
if(!tokens){
fprintf(stderr,"lsh: allocation error\n");
exit(EXIT_FAILURE);
}
}

token = strtok(NULL,LSH_TOK_DELIM);

}
tokens[position] = NULL;
return tokens;
}

int lsh_launch(char **args){
pid_t pid;
int status;

pid = fork();
if(pid == 0){
//子进程
if(execvp(args[0],args) == -1){
perror("lsh");
}
exit(EXIT_FAILURE);
}else if(pid < 0){
//错误的进程
perror("lsh");
}else{
//父进程
do{
waitpid(pid,&status,WUNTRACED);
}while (!WIFEXITED(status) && !WIFSIGNALED(status));
}
return 1;
}

int lsh_num_builtins(){
return sizeof(builtin_str) / sizeof(char *);
}

int lsh_cd(char **args){
if(args[1] == NULL){
fprintf(stderr,"lsh: expected argument to \"cd\"\n");
}else{
if(chdir(args[1]) != 0){
perror("lsh");
}
}
return 1;
}

int lsh_help(char **args){
int i;
printf("Stephen Brennan's LSH\n");
printf("Type program names and arguments,and hit enter.\n");
printf("The following are built in:\n");

for(i=0;i<lsh_num_builtins();i++){
printf(" %s\n",builtin_str[i]);
}

printf("Use the man command for information on other programs.\n");
return 1;
}

int lsh_exit(char **args){
return 0;
}

int lsh_execute(char **args){
int i;
if(args[0] == NULL){
//空指令,直接返回
return 1;
}
for(i=0;i<lsh_num_builtins();i++){
if(strcmp(args[0],builtin_str[i]) == 0)
return (*builtin_func[i])(args);
}

return lsh_launch(args);
}


  1. 在作者那个时候可能没有getline()不过后文有补充↩︎

  2. 我一开始不信,尝试并了解了一下确实如此,getchar()返回的是一个整数↩︎

  3. 作者在这里补充了一句,距离写这个文章之后过去了6年。期间他仍然是以写C语言为主,但是每次遇到函数指针的问题,他还是得谷歌一下(函数指针真的怪怪的)↩︎

简单结束了数组和字符串的学习,现在来继续学习哈希表的内容

设计哈希表

设计哈希集合

哈希表精讲 - LeetBook - 力扣(LeetCode)全球极客挚爱的技术成长平台

不使用任何内建的哈希表库设计一个哈希集合(HashSet),实现 MyHashSet 类:

  • void add(key) 向哈希集合中插入值 key 。
  • bool contains(key) 返回哈希集合中是否存在这个值 key 。
  • void remove(key) 将给定值 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
27
28
class MyHashSet(object):
def __init__(self):
self.capacity = 1e5
self.bucket = dict()

def add(self, key:int)->None:
if self.contains(key):
return
if key%self.capacity in self.bucket.keys():
self.bucket[key%self.capacity].append(key)
else:
self.bucket[key%self.capacity] = [key]

def remove(self, key:int)->None:
if not self.contains(key):
return
if key%self.capacity in self.bucket.keys():
if key in self.bucket[key%self.capacity]:
self.bucket[key%self.capacity].remove(key)

def contains(self, key:int)->bool:
if key % self.capacity in self.bucket.keys():
if key in self.bucket[key%self.capacity]:
return True
else:
return False
else:
return False

实际上这里是使用python的字典方法替代了链表的使用

设计哈希映射

哈希表精讲 - LeetBook - 力扣(LeetCode)全球极客挚爱的技术成长平台

不使用任何内建的哈希表库设计一个哈希映射(HashMap),实现 MyHashMap 类,MyHashMap() 用空映射初始化对象

  • void put(int key, int value) 向 HashMap 插入一个键值对 (key, value) 。如果 key 已经存在于映射中,则更新其对应的值 value 。
  • int get(int key) 返回特定的 key 所映射的 value ;如果映射中不包含 key 的映射,返回 -1 。
  • void remove(key) 如果映射中存在 key 的映射,则移除 key 和它所对应的 value 。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class MyHashMap(object):
def __init__(self):
self.capcity = 1e5
self.bucket = dict()

def put(self, key:int, value:int)->None:
if not key%self.capcity in self.bucket.keys():
self.bucket[key%self.capcity] = {key:value}
else:
self.bucket[key%self.capcity][key] = value

def get(self, key:int)->int:
if key%self.capcity in self.bucket.keys():
if key in self.bucket[key%self.capcity].keys():
return self.bucket[key%self.capcity][key]
return -1


def remove(self, key:int)->None:
if not key%self.capcity in self.bucket.keys():
return
if key%self.capcity in self.bucket.keys():
if key in self.bucket[key%self.capcity].keys():
del self.bucket[key%self.capcity][key]

这里同样是使用字典实现的hash映射,要注意python中常见的用于处理字典的函数操作

哈希集合

哈希集合实际上是一个不包含重复元素的集合数据结构。它只存储元素本身,不存储与元素相关联的值。我们可以用python中的set()和dict()实现,他们实际上都是使用的hash存储结构

快乐数

哈希表精讲 - LeetBook - 力扣(LeetCode)全球极客挚爱的技术成长平台

编写一个算法来判断一个数 n 是不是快乐数。「快乐数」 定义为:

对于一个正整数,每一次将该数替换为它每个位置上的数字的平方和。然后重复这个过程直到这个数变为 1,也可能是 无限循环 但始终变不到 1。如果这个过程 结果为 1,那么这个数就是快乐数。如果 n 是 快乐数 就返回 true ;不是,则返回 false 。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Solution(object):
def isHappy(self, n:int)->bool:
temp = set()
while n!=1: # 用于确定计算新的平方和
next = 0
while n!=0:
first = n%10
next += first**2
n //=10
if next in temp: # 用于检查是否存在,如果存在说明不是快乐数
return False
else:
temp.add(next)
n = next # 记得要更新n的值
return True

这里的考察实际上是对于hash存储结构的使用的考察,我们在这里用的是set()

两个数组的交集

哈希表精讲 - LeetBook - 力扣(LeetCode)全球极客挚爱的技术成长平台

给定两个数组 nums1nums2 ,返回 它们的交集 。输出结果中的每个元素一定是 唯一 的。我们可以 不考虑输出结果的顺序

1
2
3
4
5
class Solution(object):
def intersection(self, nums1:List[int], nums2:List[int])->List[int]:
set1 = set(nums1)
set2 = set(nums2)
return [x for x in set1 if x in set2]

由于python的for ... in ...查找操作相当于对hash表的查找操作,所以直接快捷方式结束了

哈希映射

哈希映射是用于存储键值对结构的,也就是python中的字典结构

同构字符串

哈希表精讲 - LeetBook - 力扣(LeetCode)全球极客挚爱的技术成长平台

给定两个字符串 s 和 t ,判断它们是否是同构的。

如果 s 中的字符可以按某种映射关系替换得到 t ,那么这两个字符串是同构的。

每个出现的字符都应当映射到另一个字符,同时不改变字符的顺序。不同字符不能映射到同一个字符上,相同字符只能映射到同一个字符上,字符可以映射到自己本身。

1
2
3
4
5
6
7
8
9
class Solution(object):
def isIsomorphic(self, s:str, t:str)->bool:
st,ts = {},{}
for x,y in zip(s,t):
if x in st and st[x] != y or y in ts and ts[y] != x:
return False
else:
st[x],ts[y] = y,x
return True

这里的zip()用于将多个可迭代对象(列表,数组等)打包成同一个对象,这里是将其打包为了一个字典对象

第一个唯一字符

387. 字符串中的第一个唯一字符 - 力扣(LeetCode)

给定一个字符串 s ,找到 它的第一个不重复的字符,并返回它的索引 。如果不存在,则返回 -1

1
2
3
4
5
6
7
class Solution:
def firstUniqChar(self, s: str) -> int:
frequency = collections.Counter(s)
for i, ch in enumerate(s):
if frequency[ch] == 1:
return i
return -1

???内置函数

我突然后悔了,我本来学算法是希望能学一点相关的算法知识,但是我报了Python 赛道,发现根本不是在学算法,感觉像是在学语法

信息安全法律基础的老师留了一个问题,和朋友石头剪刀布输了,所以是我来回答这个问题。那么

人生的价值是什么?

以前也想过这个问题,但更多是讲给别人听的,告诉他们要积极要向上。但是很少认真的考虑这个问题,拿出一个让我自己的满意的答案。寻寻觅觅,想了一段时间,感觉大概有能拿出来说一说的想法了吧。所以记录在这里:

一串数字,它可以是你的手机密码,可以是你暗恋的女生的生日,可以是路口第一辆车的车牌号,也可以是仅仅只是一串数字。不同的场景,不同的人看到它都会有着各种各样的理解与认识。我想这串数字本没有意义,它的“价值”是被人为的赋予的。就像是棋盒里的黑白棋子,它本来并不包含什么信息,但是当它落在棋盘中的瞬间,却被赋予各种各样的信息与“价值”;亦或是程序中的二进制,对于人类而言,杂乱无章毫无意义,但是在机器的眼中却是他们工作的金科玉律。

我想,人也大概如此。人生,剥离与外界的关系,无非是几句话加上几个动作,从本质来看也是这么一串”数字”。它的本身并没有什么意义,但是将它与外界联系起来,人生就有了无穷的意义。所以我认为,人生的价值更多是被”人”赋予的,你的行为和话语,会产生各种各样的影响,对不同的人产生不同的价值。可能一次无意间的囧事,陌生的人会嘲笑你,熟悉你的人会安慰你,爱你的人会包容你。这是因为大家对你的认识与理解不同,你的囧事本质上是一堆信息,但是被赋予的“价值”不同。想到这里,也许会觉得很恐怖吧,自己的价值依附于他人的评价。当然不!你还有自己,你也是“人”,你也可以为自己的行为赋予各种各样的意义。我想这就是这个问题的关键,价值是被人赋予的,这其中既有别人,也有自己,人生不仅被他人定义,也由自己决定。

外界的声音总是嘈杂的,就算是圣人也有人批判。所以人生的价值应从自己出发,《孟子》中有这么一句话“穷则独善其身,达则兼济天下”,我觉得人生的价值就是这样,从自己开始,由己及外,去探索自己的价值。首先是要对自己好一点,满足自我的价值。然后再去实现对于他人的价值,而不是好高骛远的,渴望所有人的认可。英国伦敦有一座教堂,旁边的碑石有这么一句话:

“当我年轻的时候,我梦想改变这个世界;

当我成熟以后,我发现我不能改变这个世界,我将目光缩短一点,打算只改变我的国家;

当我进入暮年以后,我发现我不仅不能改变我的国家,我的最后愿望仅仅是改变一下我的家庭,但是这也是不可能的

我现在躺在床上,行将就木之时,我突然意识到:

如果一开始我仅仅去改变自己,然后可能会改变我的家庭;

然后在家人的支持和帮助下,我可能会为国家做一点事;

然后,谁知道呢?我甚至可以改变这个世界”

正如《礼记》中“修身,齐家,治国,平天下”的抱负,我们追求的人生价值大抵也该如此吧。

但是我真正想说的并不是这些,让一个十八岁的大学生去大谈人生价值就像是问四五岁的小朋友为什么想当科学家一样,没有什么参考价值。未来我还会经历各种各样的事情,而现在的我,知道的太多,经历的却太少。人生的价值?我也不太清楚。如果你问我人生的价值是什么,人生的意义是什么?我现在更想打会儿游戏,听听歌,和喜欢的女生聊聊天,吃点想吃的东西,学点想学的技术。

这是第二遍学KMP算法,第一遍学习的过程中,对他的原理理解的是云里雾里。现在回过头来,深度学习一下KMP算法

首先我们要清楚什么是KMP算法?

KMP

KMP算法的优点相较于暴力匹配算法(BF)更有效率。这是因为KMP算法会利用每次匹配得到的已有的信息,来进行回溯。从而开始一次新的匹配。而暴力算法由于重复的匹配,其复杂度为 O(m*n);KMP算法的主串指针始终向前,所以复杂度为 O(m+n)

next数组

我看很多教程一开始都是先讲KMP算法的实现,只是提了一嘴next数组的大致内容,我觉得挺难懂的。所以这里先从next数组开始讲起:

要讲next数组,我们首先要从字符串的最大公共前后缀讲起,下面的图片很好的体现了这个概念。每次增大子串的长度,然后求得其对应的最大公共前后缀长度。

image.png

在求的子串的最大公共前后缀组后,我们便可以着手开始建立我们的next数组。

(1)首先我们需要明确next数组在这里的作用是什么?

当模式串的某个字符与主串的某个字符失配时,我们需要根据失配的位置 j,还有我们的 next数组,来确定下一次模式串开始匹配的位置。下一步用 next[j]处的字符继续与主串 i处的字符进行匹配。相当于将模式串向右移动了 j-next[j]的长度

(2)接下来的难点在于如何构建next数组

首先我们让 next[0] = -1作为一个标记,且我们容易得到 next[1] = 0,那么在此基础之上,我们需要结合现有的信息来递推下一个 next[j+1]的值。现在问题转换成了,如何利用已有信息,求下一个 next[j+1]的值?

对此我们需要分成两种情况进行判断:

  • p_k = p_j时,next[j+1] = next[j] + 1 = k+1,代表该字符前有最大长度为k+1的最大公共前后缀
image.png
  • p_k != p_j时,我们就需要寻找更短的最大公共前后缀,此时关键来了,怎么找寻更短的最大公共前缀?
image.png

​ 因为 P_k!=P_j,所以我们需要再次尝试 P_next[k]P_j的比较,如此一直递归下去。直到得到 P_next[next[...]]P_j相 等,从而令 next[j+1] = next[index[P_next[…]]] + 1 = k+1;或者始终不能成功配对,则令 next[j+1] = 0

现在我们可以写出next数组的递推算法:

1
2
3
4
5
6
7
8
9
10
11
12
void GetNext(char T[]){
int j=0,k=-1;
next[j] = k;
while (T[j]!='\0'){
if(k==-1||T[j]==T[k]){
j++;
k++;
next[j]=k;
}
else k=next[k];
}
}

这个程序还有一个递归版本:

1
2
3
4
5
6
7
8
9
10
11
12
int GetNext(int j,char T[]){
if(j==0)return -1;
if(j>0){
int k = GetNext(j-1,T);
while(k>=0){
if(T[j-1]==T[k])return k+1;
else k=GetNext(k,T);
}
return 0;
}
return 0;
}

KMP算法

我们在得到next数组之后,便可以根据next数组的特性和简单的判断,来实现我们的KMP算法,以下是我们的算法流程:

  • 如果j = -1,则i++,j++,继续匹配下一个字符
  • 如果S[i] = T[j],则i++,j++,继续匹配下一个字符
  • 如果j != -1,且S[i] != P[j],则i不变,j = next[j]。这意味着失配,接下来模式串需要对主串相对右移j-next[j]的距离

现在我们可以写出完整的KMP算法:

1
2
3
4
5
6
7
8
9
10
11
12
int KMP(int start,char S[],char T[]){
int i = start,j = 0;
while(S[i]!='\0'&&T[j]!='\0'){
if(j==-1||S[i]==T[j]){
i++; //下一个字符的比较
j++; //模式串右移
}
else j = next[j];
}
if(T[j]=='\0') return(i-j); //匹配成功则返回下标(当前比较位-比较长度 = 起始下标)
else return -1;
}

当然,在KMP的基础之上,还有很多优化之后的算法,在此就不过多赘述

Python版本

这个是python版本的KMP算法,基本上一样,只不过边界条件的判断改为使用长度判断。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class Solution(object):
def strStr(self, haystack:str, needle:str)->int:
next = self.getnext(needle)
h,n = 0,0
while h<len(haystack) and n<len(needle):
if n==-1 or haystack[h] == needle[n]:
h += 1
n += 1
else:
n = next[n]
if n == len(needle):
return h-n
else:
return -1

def getnext(self,T:str)->List[int]:
j=0
k=-1
next = [0 for i in range(len(T))]
next[j]=k
while j <len(T)-1:
if k==-1 or T[j]==T[k]:
j+=1
k+=1
next[j] = k
else:
k = next[k]
return next

继续算法的日常练习

字符串

最大公共前缀

数组和字符串 - LeetBook - 力扣(LeetCode)全球极客挚爱的技术成长平台

编写一个函数来查找字符串数组的最大公共前缀;如果不存在,则返回 ""

1
2
3
4
5
6
7
8
9
10
11
12
def longestCommonPrefix(self, strs:List(str))->str:
min_str = min(strs, key=len)
result = ""
p = 0
while p < len(min_str):
char = strs[0][p]
for s in strs:
if s[p] != char:
return result
result += char
p += 1
return result

先纵向比对第一个字符,如果均为相同,则加入返回值中;若不正确,则直接返回

最长回文子串

数组和字符串 - LeetBook - 力扣(LeetCode)全球极客挚爱的技术成长平台

给你一个字串s找到s中最长的子串,并返回

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution(object):
def longestPalindrome(self, s:str)->str:
ans = ""
for i in range(len(s)):
temp = self.search(s,i,i)
if len(temp) > len(ans):
ans = temp
temp = self.search(s,i,i+1)
if len(temp) > len(ans):
ans = temp
return ans

def search(self,s,first,end):
while first>=0 and end<len(s) and s[first] == s[end]:
first -= 1
end += 1
return s[first+1:end]

首先构造一个中心回文的函数 search,先为两边设置边界条件,然后当中心索引两边的值相等时,则向外拓展。我们构造这个辅助函数之后,在主函数中调用。(需要考虑奇偶回文的情况)

翻转字符串里的单词

数组和字符串 - LeetBook - 力扣(LeetCode)全球极客挚爱的技术成长平台

给你一个字符串s,反转字符串中单词的顺序

注意:输入字符串 s中可能会存在前导空格、尾随空格或者单词间的多个空格。返回的结果字符串中,单词间应当仅用单个空格分隔,且不包含任何额外的空格。

1
2
def reverseWords(self, s:str)->str:
return (" ").join(s.split()[::-1])

啊,因为python丰富的内置函数,直接做完了。可以稍微讲下这几个函数的用法:

  • ("拼接符号").join(待拼接数组)
  • (待分隔数组).split(分隔符号)

双指针

指针常用的两种用法:

  • 首尾指针
  • 快慢指针

反转字符串

数组和字符串 - LeetBook - 力扣(LeetCode)全球极客挚爱的技术成长平台

编写一个函数,其作用是将输入的字符串反转过来。输入字符串以字符数组 s 的形式给出。

不要给另外的数组分配额外的空间,你必须原地修改输入数组、使用 O(1) 的额外空间解决这一问题

1
2
3
4
5
6
7
def reverseString(self, s):
i,j = 0,len(s)-1
while i<j:
s[i],s[j] = s[j],s[i]
i += 1
j -= 1
return s

这里实际上是用了双指针的思想,相对移动然后互换位置。python中虽然没有指针,但是我们仍然可以通过数组去模拟这个过程,不过设置边界条件时一定要注意

数组拆分

数组和字符串 - LeetBook - 力扣(LeetCode)全球极客挚爱的技术成长平台

给定长度为 2n 的整数数组 nums ,你的任务是将这些数分成 n 对, 例如 (a1, b1), (a2, b2), …, (an, bn) ,使得从 1 到 n 的 min(ai, bi) 总和最大。返回该 最大总和

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Solution(object):
def arrayPairSum(self, nums:List[int])->int:
ans = 0
nums = self.quick_sort(nums)
print(nums)
for i in range(len(nums)//2):
ans += nums[i*2]
return ans

def quick_sort(self,array):
if len(array) <= 1:
return array
else:
reference = array[0]
small = [s for s in array[1:] if s < reference]
equal = [e for e in array if e == reference]
large = [l for l in array[1:] if l > reference]
return self.quick_sort(small) + equal + self.quick_sort(large)

这道题主要在于分析怎么得到最大总和,观察可以发现先对数组进行排序,再将已排序数组的偶数位累加即可。排序的话要尽量使用,较快的排序,不然会超时(冒泡就超了),所以这里使用快速排序

两数之和

数组和字符串 - LeetBook - 力扣(LeetCode)全球极客挚爱的技术成长平台

给你一个下标从 1 开始的整数数组 numbers ,该数组已按 非递减顺序排列 ,请你从数组中找出满足相加之和等于目标数 target 的两个数。如果设这两个数分别是 numbers[index1] 和 numbers[index2] ,则 1 <= index1 < index2 <= numbers.length 。

以长度为 2 的整数数组 [index1, index2] 的形式返回这两个整数的下标 index1 和 index2。

你可以假设每个输入 只对应唯一的答案 ,而且你 不可以 重复使用相同的元素。

1
2
3
4
5
6
7
8
9
10
def twoSum(self, numbers:List[int], target:int)->List[int]:
i,j = 0,len(numbers)-1
while True:
a = numbers[i]+numbers[j]
if a>target:
j -= 1
elif a<target:
i += 1
else:
return [i+1,j+1]

对于这类题可以用一种匹配方式,有效的减少重复的索引:

  • 双指针指向首尾,求和与target进行比较
  • 当值较小时,我们将首指针前移
  • 当值较大时,我们将尾指针后移

移除元素

数组和字符串 - LeetBook - 力扣(LeetCode)全球极客挚爱的技术成长平台

给你一个数组 nums 和一个值 val,你需要 原地 移除所有数值等于 val 的元素。元素的顺序可能发生改变。然后返回 nums 中与 val 不同的元素的数量。

假设 nums 中不等于 val 的元素数量为 k,要通过此题,您需要执行以下操作:

  • 更改 nums 数组,使 nums 的前 k 个元素包含不等于 val 的元素。
  • nums 的其余元素和 nums 的大小并不重要。
  • 返回 k
1
2
3
4
5
6
7
def removeElement(self, nums:List[int], val:int)->int:
slow = 0
for fast in range(len(nums)):
if nums[fast] != val:
nums[slow] = nums[fast]
slow += 1
return slow

快慢指针,一个负责定位,一个负责向前索引匹配。这个思想十分常见

最大连续的1的个数

数组和字符串 - LeetBook - 力扣(LeetCode)全球极客挚爱的技术成长平台

给定一个二进制数组 nums , 计算其中最大连续 1 的个数

1
2
3
4
5
6
7
8
9
10
def findMaxConsecutiveOnes(self, nums: List[int]) -> int:
a,b = 0,0 # 分别存储当前最大连续数与历史最大连续数
for i in range(len(nums)):
if nums[i] == 0:
if a > b:
b = a
a = 0
else:
a += 1
return max(a,b)

这里,我用了一个比较清晰的答案,同时这里也体现了快慢指针的思想,很优雅

长度最小的子数组

数组和字符串 - LeetBook - 力扣(LeetCode)全球极客挚爱的技术成长平台

给定一个含有 n 个正整数的数组和一个正整数 target 。

找出该数组中满足其总和大于等于 target 的长度最小的 子数组 [numsl, numsl+1, …, numsr-1, numsr] ,并返回其长度。如果不存在符合条件的子数组,返回 0 。

1
2
3
4
5
6
7
8
9
10
11
12
def minSubArrayLen(self, target:int, nums:List[int])->int:
left,right = 0,0
count = len(nums)+1
sum = 0
while right < len(nums):
sum += nums[right]
while sum >= target:
count = min(count,right-left+1)
sum -= nums[left]
left += 1
right += 1
return 0 if count==len(nums)+1 else count

这里使用了尺缩法,其大致流程如下:

  • 右边界右移,直到和大于target,令左边界右移
  • 若和仍然大于target,左边界继续右移
  • 若和小于target,右边界右移

这种和上面的相对收缩有着异曲同工之妙,但是这里是追及收缩,要注意边界条件

互联今日

我本来有好多好多话想说啊

写着写着什么也写不下去了

很怀念以前在网上认识的朋友们

再看看如今的网络环境 唏嘘不已

用Python感受十大排序算法,首先我们对这十个排序进行分类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
十大排序算法:
非线性时间比较类:
交换类:
冒泡排序
快速排序
插入类:
直接插入排序
希尔排序
选择类:
简单选择排序
堆排序
归并类#:
归并排序
线性时间排序:
计数排序
桶排序
基数排序

非线性排序

冒泡排序

最经典的排序算法

算法思想

将数据中的每个数与相邻的数进行比较并交换,大数往上冒,小数向下沉。将每个数遍历排序一遍

算法步骤

  • 取第一个数,依次与下一个数进行排序,然后与小于(或大于)自己的数交换位置直到最后一个数
  • 如果未发生位置交换,说明数据有序,排序结束。如果发生了位置交换重复上一步

算法分析

时间复杂度:

  • 当排序为正序时,只需要排序一次,比较n-1次,时间复杂度为O(n),这是最优情况
  • 当排序为逆序时,需要进行n-1次遍历,每次比较n-i次,时间复杂度为O(n^2),这是最差情况

算法代码

1
2
3
4
5
6
7
8
9
10
11
# 冒泡排序
def bubble_sort(nums):
# 冒泡排序进行的次数n-1,每次遍历确定一个最大值
for i in range(len(nums)-1):
# j为进行比较的数据下标
for j in range(len(nums)-1-i):
# 若下标为j的数据大于其右相邻数据
if nums[j] > nums[j+1]:
# 二者交换数据
nums[j],nums[j+1] = nums[j+1],nums[j]
return nums

快速排序

也称为分区交换排序,它采用了分治思想,是冒泡排序的改良版。冒泡排序只能比较和交换两个数据,但是快速排序可以交换和比较两个分区,其效率更高

算法思想

选取一个基准值,将待排序数据分为左(小于基准值)和右(大于基准值)两个区间,然后对两个分区的数据进行相同的操作,最终得到一组有序数据

算法步骤

  • 选取待排序数据的第一个数值作为分区标准
  • 遍历数组,将小于标准的放在左边,大于标准的放在右边,则中间为标准数
  • 对标准数左右两个子序列分别进行(1)和(2)的操作
  • 当左右序列的长度均小于等于1时,排序完成

算法分析

时间复杂度

  • 如果选取的标准数位待排序数组的中位数,即每次划分后的左右子序列长度基本一致,时间复杂度为 O(n*log_2(n)),为最好情况
  • 如果待排序数组是逆序,且第一趟选取的标准数位待排序数组的最大值,经过n-1次比较后,得到一个n-1个元素的左子序列;然后第二趟选取的标准数仍然是最大值,…依次类推,最终的时间复杂度为 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
# 快速排序
def quick_sort(array):
if len(array)<=1: # 当子序列长度<=1
return array # 排序完成,停止递归
else:
small = [] # 由所有小于基准值的元素组成的子数组
equal = [] # 包括基准值在内并且和基准相等的元素
large = [] # 由所有大于基准值的元素组成的子数组
reference = array[0] # 选择第一个数作为基准值
# 遍历array中的每一个元素
for s in array:
# 当前元素小于基准值时
if s < reference:
# 当前元素插入small
small.append(s)
# 当前元素等于基准值
elif s == reference:
equal.append(s)
# 当前元素大于基准值
else:
large.append(s)
print(small,equal,large)
# 调用自身,即为递归
return quick_sort(small) + equal + quick_sort(large)

实用版:

1
2
3
4
5
6
7
8
9
def quick_sort(array):
if len(array) <= 1:
return array
else:
reference = array[0]
small = [s for s in array[1:] if s < reference]
equal = [e for e in array if e == reference]
large = [l for l in array[1:] if l > reference]
return quick_sort(small) + equal + quick_sort(large)

直接插入排序

直接插入法是直接排序的典型方法,毫无技术可言

算法思想

将排序数组中的数据逐一插入已排序的子序列中,从而得到一个完整的有序数组

算法步骤

  • 将数组的第一个数据看成一个有序的的子序列
  • 从第二个数据开始,依次与前面的有序子序列进行比较,若待插入数据array[i]大于或等于array[i-1],则原位插入;若待插入数据array[i]小于数据array[i-1],则将array[i]临时存在临时变量temp中,将有序序列中大于temp的所有数据都后移一位,然后将temp插入对应的位置
  • 重复步骤(2)

算法分析

时间复杂度:

  • 当数组为正序排列时,比较次数为n-1,移位次数为0,均为最小值,因此时间复杂度为 O(n)
  • 当数组为逆序排列时,每次比较都需要做一次位移,此时时间复杂度为 O(n^2),是最差情况

空间复杂度:

O(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
# 直接插入排序
def inset_sort(array):
n = len(array)
# 切记是从1到n-1,前闭后开
for i in range(1,n):
# 如果待插入数据小于前一个数据
if array[i] < array[i-1]:
# index为待插入的下标
index = i
# 将待插入的数据放在temp中
temp = array[i]
# 从i-1循环到0,反向取值,间距为1
# 前两个参数为区间范围,第三个参数为取值方向和大小
for j in range(i-1,-1,-1):
# 如果array[j]大于temp
if array[j] > temp:
# 将array[j]后移一位
array[j+1] = array[j]
# 更新待插入的下标
index = j
# 如果array[j]小于或等于temp
else:
# 跳出并结束
break
# 将temp插入有序子数列
array[index] = temp
# 如果待插入数据大于前一个数据,则不需要移动
return array

希尔排序

即shell排序,也叫缩小增量排序,通过将原始列表分解为多个子列表来改进插入排序

算法排序

shell排序摒弃了直接插入排序逐一比较的区别,而是对指定距离(增量d)的元素进行比较,并不断把增量减小到1,直到排序完成

算法步骤

  • 选取增量d = n/2
  • 将排序数组分成d个子序列,然后分别进行直接插入排序
  • 将增量取半,即 d = d/2 = n/2/2
  • 重复步骤(2)和(3)
  • 对整个待排序数组进行直接插入排序

算法分析

似乎希尔排序并不比直接插入排序快多少,但是我们可以注意到它的特点:

  • 当d较大时,分组多,每组记录少,因此直接插入排序快
  • 当d教小时,分组少,每组记录多,但是此时各组记录已经为正序,因此排序比正常的直接插入要快

实际上希尔排序相较于直接排序,主要是优化了待排序数组的初始顺序,使其达到或接近于直接插入排序的最优情况

时间复杂度:

  • 最好的情况下为O(n^1.3)
  • 最差的情况仍然为O(n^2)

算法代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 希尔排序
def shell_sort(array):
# 设定初始增量d=n/2
d = len(array)//2
# 当增量大于0时执行,如果小于0,则退出循环
while d>0:
# i 的取值范围为d到n
for i in range(d,len(array)):
# 类似直接插入排序,比较当前值与指定增量的值
while i>=d and array[i-d]>array[i]:
# 符号条件则交换
array[i],array[i-d] = array[i-d],array[i]
# 取前一个指定增量的值,继续下一个判断
i -= d
# 将增量取半,回到while循环
d = d//2
return array

简单选择排序

选择排序的思想和简单,每次从待排序数据中选择最小的一个放在最前面,直到将所有数据都遍历完

算法思想

遍历待排序数组并选出其中的最小数据元素,并于第一个元素交换位置,第二小的元素和第二个元素交换位置,直到剩下最后一个数据为最大数据,排序结束

算法步骤

  • 将第一个位置上的元素依次与后面的元素进行比较,若前者大,则交换二者位置
  • 重复步骤(1),比较第二个位置上的元素,然后比较第三个位置上的元素…直到比到倒数第二个元素

算法分析

这个算法被视作在排序算法中最差劲的算法

时间复杂度:

无论时最好的情况还是最差的情况,都需要与所有数据进行遍历,因此时间复杂度始终为 O(n^2)

算法代码

1
2
3
4
5
6
7
8
9
10
11
12
13
# 简单选择排序
def select_sort(array):
# 从0遍历到待排序数组的长度len(array)-1
for i in range(len(array)-1):
# smallest为最小值的index,初始默认为当前的i
smallest = i
# j为比较index,从当前的index的下一位到数组结束
for j in range(i+1,len(array)):
# 如果当前最小值大于当前值
if array[smallest]>array[j]:
# 则二者交换位置
array[smallest],array[j] = array[j],array[smallest]
return array

堆排序(这个和数据结构相关,我不是太理解)

简单排序只顾着,一次又一次的重新比对,却不对每一次的比较结果进行分析保存,所以导致效率低下,而堆排序加以改进

算法思想

首先需要直到什么是堆,堆是用数组实现的已标号的完全二叉树。接着需要引入小顶堆和大顶堆的思想:

  • 如果父节点的键值均不小于子节点,则为大顶堆:arr[i]>=arr[2*i+1] && arr[i]>=arr[2*i+2]
  • 如果父节点的键值均不大于子节点,则为小顶堆:arr[i]<=arr[2*i+1] && arr[i]<=arr[2*i+2]

也就是说,堆排序实际上就是一次又一次的将待排序数组构造成一个大顶堆,又一次次的将大顶堆的根节点(最大值)和最末尾的元素互换位置,并末尾的元素隔离,直到整个序列变得有序

算法步骤

  • 将待排序数组构建成一个大顶堆(如果降序排列,则使用小顶堆)。为了构建大顶堆,我们需要从从最后一个非叶子节点开始先从上到下,再从左往右进行调整。最后一个非叶子节点的计算公式为 arr.length//2-1
  • 将堆顶元素与末尾元素交换,使最大元素沉至数组末尾
  • 重复步骤(1)和(2),直到整个序列都变得很有序。重新调整数组结构,使其满足大顶堆的结构

算法分析

时间复杂度:

  • 最好的情况是正序,只需要进行 O(n*log_2(n)复杂度的比较操作,而不需要移动
  • 最坏的情况时间复杂度还是O(n*log_2(n)
  • 从上面可以看出待排序数据的原始分布情况堆堆排序的效率影响较少

算法代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# 堆排序
def heap_sort(array):
"""
需要注意两点:
(1)递归思想
(2)切片思想
"""
length = len(array)
# 当数组array的长度为1时,说明只有一个元素
if length <= 1:
return array
# 若存在两个及以上的节点
else:
# 调整为大顶堆,按照从下往上从左往右的顺序进行调整
# 从最后一个非叶子节点开始想前遍历,直到根节点
for i in range(len(array)//2-1,-1,-1):
# 当左孩子大于父节点
if array[2*i+1]>array[i]:
# 交换位置
array[2*i+1],array[i] = array[i],array[2*i+1]
# 如果右孩子存在且大于父节点
if 2*i+2 <= length-1:
if array[2*i+2]>array[i]:
# 交换位置
array[2*i+2],array[i] = array[i],array[2*i+2]
'''此处省略剩余数组重构过程,对结果无影响'''
# 将堆顶元素与末尾元素进行交换
array[0],array[length-1] = array[length-1],array[0]
# 递归调用heap_sort函数堆前n-1个元素进行堆排序并返回堆排序后的结果
return heap_sort(array[0:length-1]) + array[length-1:]

归并排序

归并排序是包含归并思想的排序方法,它是分治法的一个应用。分治法即“分而治之”

算法思想

先将原数组分为子序列,一生二,二生四,四生无穷,然后使每个子序列有序,再将两个有序子序列合并为一个有序序列,最终合一

算法步骤

  • 将排序数组一分为二,再将两个子序列一分为二,生成两个新的待排序数组
  • 重复步骤(1),直到待排序数组的长度为1
  • 按原路径将长度为1的两个数合并为一个有序序列,然后一直向前合并,最终得到一个完整的有序序列

算法分析

归并排序十分高效,它的分与合的结构都是完成二叉树,而分和合的二叉树深度均为log_2(n),而每次分合的时间复杂度为 O(n),故

时间复杂度:

始终为 O(n*log_2(n)),不论情况好坏

算法代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
# 归并排序
def merge(left,right):
"""
两个有序子序列的有序合并:
依次对两个有序列表的最小数进行比较,较小的放入result中
:param left: 左子序列
:param right: 右子序列
:return: 左右子序列所合成的有序序列
"""
# result: 存好已经排序的数组
result = []
# 如果不符合左右子序列长度均大于0,则说明至少其中的一个数组已经无数据了
while len(left)>0 and len(right)>0:
# 相等的时候优先把左侧的数放进结果列表,以保证其稳定性
if left[0] <= right[0]:
# list.pop(0)为移除并返回数组的第一个值
result.append(left.pop(0))
else:
result.append(right.pop(0))
# 跳出while循环后,我们便可以把另一个数组尽数加入result后面
result += left
result += right
return result

def merge_sort(array):
"""
无序序列的不断拆分
每次均由中间位置进行拆分,不断自我递归调用,直到子序列长度为1
"""
# 如果拆分后仅有单个元素,则返回该元素而不拆分
if len(array) == 1:
return array
# 如果有两个以上元素,则从中间位置开始拆分
middle = len(array)//2
# 拆分后的左侧子串
array_left = array[:middle]
# 拆分后的右侧子串
array_right = array[middle:]
# 对拆分后的左右子串序列再进行拆分,直到len(arr)=1
left = merge_sort(array_left)
right = merge_soort(array_right)
# 合并已拆分的左右子序列的同时进行排列并返回排列后的结果
return merge(left,right)

线性时间非比较类排序

计数排序

一种非基于比较的排序算法,通过辅助数组来确定各元素的为最终位置。因为在排序过程中不存在元素之间的比较和交换操作,当待排序数组为整数且数组内数据范围较小时,其优势十分明显

算法思想

对待排序数组中的每一个元素分别进行计数,确定整个数组中小于当前元素的个数,计数完成后便可以按照各计数值将各元素直接存放在已排序的数组中

算法步骤

  • 根据待排序序列中的最大元素和最小元素确定计数数组c的空间大小
  • 统计待排序序列中的每个元素i出现的次数并存入c[i-1]
  • 对c中的所有值进行累加,后者为其前面所有的值的总和
  • 将每个元素放入已排序序列的第c[i]项,每放入一个元素,c[i]便减1
  • 所有的元素都放入或者c[i]均为0之后,排序结束

算法分析

我们可以观察到计数排序的弊端,当最大值与最小值间的差值过大,会使开销增大,性能变差.其次当出现浮点数或其他形式时,也会有问题

时间复杂度:

第一个循环用于记录每个元素出现的次数,复杂度为 O(n)第二个循环用于对计数数组进行累加,复杂度为 O(k),k为申请空间的大小(即差值范围),第三个循环用于反向填充已排序的数列,复杂度为O(n).总结可得,时间复杂度为 O(n+k)

空间复杂度:

由于我们额外申请了一个大小为k的计数数组和一个与待排序数组同样大小的排序空间,所以空间复杂度为 O(n+k)

算法代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 计数排序
def count_sort(arr):
# 计算序列的最大值和最小值
maximum,minimum = max(arr),min(arr)
# 申请额外的空间作为计数数组,大小为最大值减最小值+1
countArr = [0 for i in range(maximum-minimum+1)]
# 申请一个存放已排序序列的等长空间
finalArr = [0 for i in range(len(arr))]
# 统计各元素出现的次数,并将统计结果存入计数数组
for i in arr:
# 出现一次就加1
countArr[i-minimum] += 1 # 注意下标index = data - min
# 对计数数组进行累加
for i in range(1,len(countArr)):
# 从第二个位置开始,每个每个位置的值等于其本身加上前者
countArr[i] += countArr[i-1]
# 开始将元素反向填充到最终的数组中
for i in arr:
finalArr[countArr[i-minimum]-1] = i
# 填充了一个就减一
countArr[i-minimum] -= 1
return finalArr

桶排序

这是计数排序的改进版本

算法思想

根据元素的特性将集合拆分成为多个值域,我们称之为桶,将同一值域的元素存放在同一个桶内排序使其处于有序状态.如果每个桶是有序的,则由这些桶按顺序构成的集合也必定是有序的

算法步骤

  • 根据待排序序列中元素的分布特征和差值范围,确定映射函数与申请桶的个数
  • 遍历序列,将每个记录放到对应的桶中
  • 选取排序算法,对不为空的桶内部进行排序
  • 把已排序的桶中的元素放回已经清空的原序列中

算法分析

我们可以注意到,桶排序和快速排序与计数排序的相似性。为了加深理解,我们在此进行区分:

  • 快速排序是对两个分区的排序,而桶排序是多个分区
  • 快速排序是原地排序方式,即在数组本身内进行排序,而桶排序则是在申请的额外操作空间进行排序
  • 快速排序中的每个区间仍然是快速排序,而桶排序可以自主选择桶内的排序方式
  • 计数排序的申请空间的跨度是从最小元素到最大元素,受待排序序列的范围的影响很大,而桶排序弱化了这种影响,减少了元素大小不连续时计数排序所存在的空间浪费

这里对于桶排序要抓住两个关键点:元素值域的划分排序算法的选择

时间复杂度:

映射过程的排序复杂度为 O(n)桶内排序算法的时间复杂度则和选择的排序复杂度相关

空间复杂度:

由于桶排序需要申请额外的空间进行分桶操作,所以空间复杂度为 O(m+n)

算法代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 桶排序
def bucket_sort(arr):
maximum,minimum = max(arr),min(arr)
# 确定桶的个数
buckets = [[] for i in range(maximum-minimum)//10+1]
for i in arr:
# 计算各元素的所属的桶位
index = (i - minimum)//10
# list.append()---添加元素
buckets[index].append(i)
# 将原数组清空
arr.clear()
for i in buckets:
# 桶内排序---堆排序(可以用别的替代)
i = heap_sort(i)
# 将已排序的桶重新放回已经清空的原数组中
arr.extend(i)
return arr

基数排序

基数排序是桶排序的扩展,因此又称之为”桶子法”,它是通过键值的部分信息,将要排序的元素分配至”桶”中,以达到排序作用

算法思想

将各元素按位数切割成不同的数字,然后分别根据每个位数的比较结果进行排序

算法步骤

  • 确定待排序序列的最大位数
  • 将所有待排序元素统一为最大数位长度(左补0)
  • 从最低位到最高位分别进行排序
  • 当最高位排序完成后,原数组变成有序数列

算法分析

时间复杂度:

每一轮排序的时间复杂度为 O(n),而轮数由最大值的位数d决定,因此时间复杂度为 O(d*n)

空间复杂度:

需要额外申请空间k,因此空间复杂度为 O(n+k)

算法代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 基数排序
def radix_sort(arr):
maximum = max(arr) # 确定待排序数组中的最大位数
d = 0 # 用d记录最大位数
while maximum!=0:
maximum = maximum//10
d += 1
# d轮排序
for i in range(d):
# 因为每一位数字都是0~9,所以建十个桶
s = [[] for k in range(10)]
for j in arr:
# 从个位数开始注意进行分桶操作
s[int(j/(10**i))%10].append(j)
# 用10个桶回填原数组
arr = [a for b in s for a in b]
return arr

四月份还是三月份有蓝桥杯的算法比赛,加上一直对算法感兴趣。所以从现在开始稍微学一下。

每天会坚持做几题吧,因为不知道哪些比较重要,我就主要记一些我认为重要的内容和算法

数组

数组中心索引

数组和字符串 - LeetBook - 力扣(LeetCode)全球极客挚爱的技术成长平台

当数组中一个索引右边的和等于左边和时,称这个索引为数组的中心索引

1
2
3
4
5
6
7
8
9
def midIndex(nums:List[int])->int:
all = sum(nums)
left = 0
for i in range(len(nums)):
if left*2+nums[i]==all:
return i
else:
left += nums[i]
return -1

大概的思路就是根据它的数值对称的思路来实现中心索引的寻找

二分法查找

数组和字符串 - LeetBook - 力扣(LeetCode)全球极客挚爱的技术成长平台

这是一个经典的查找算法,相较于线性查找,它的时间复杂度更简单O(log_n)

1
2
3
4
5
6
7
8
9
10
11
def binary_search(nums:List[int],target:int)->int:
left,right = 0,len(nums)-1
while left<=right:
mid = left + (right-left)//2 # 避免整数溢出
if nums[mid] == target:
return mid
elif nums[mid] < target:
left = mid+1
else:
right = mid-1
return -1

首先设置左右边界,然后判断目标在左区间还是右区间。如果在左区间,则更改右边界条件;在右区间,则更改左边界条件。直到找到目标数值,返回索引

区间合并

数组和字符串 - LeetBook - 力扣(LeetCode)全球极客挚爱的技术成长平台

给一个若干区间组成的数组,其区间含义为[start,end],我们将其进行合并

1
2
3
4
5
6
7
8
9
def merge(self, intervals: List[List[int]]) -> List[List[int]]:
intervals.sort(key= lambda x:x[0])
rList = list()
for interval in intervals:
if not rList or rList[-1][-1]<interval[0]:
rList.append(interval)
else:
rList[-1][-1] = max(rList[-1][-1],interval[1])
return rList

首先根据区间的起始位置进行排序,然后创建一个新的列表,设置以下规则:

  • 当列表为空时,加入数组
  • 当列表中数组的最后一位小于当前数组的第一位,说明不重叠,加入数组
  • 当发生重叠时,比较两数组的最后一位,保留大的数字

二维数组

旋转矩阵

数组和字符串 - LeetBook - 力扣(LeetCode)全球极客挚爱的技术成长平台

给你一个NXN矩阵表示的图像,其中每个像素大小为4字节,设计一种算法,将图像顺时针旋转90°

方法一:

使用一个额外的数组来辅助完成

1
2
3
4
5
6
7
8
def rotate(self,matrix: List[List[int]])->List[List[int]]:
rlists = list()
for j in range(len(matrix[0])):
rlist = list()
for i in range(len(matrix)-1,-1,-1):
rlist.append(matrix[i][j])
rlists.append(rlist)
return rlists

这个实际上是通过更改读取顺序的方式,重新创建一个矩阵,但是不符合题目不额外使用空间的思想

方法二:

通过矩阵的几何特性来实现

1
2
3
4
5
6
7
8
9
def rotate(self,matrix: List[List[int]]):
N = len(matrix)
# 对角线互换
for i in range(N):
for j in range(i): # 这里要仔细理解,因为接下来要进行互换,所以j要随i增大
matrix[i][j],matrix[j][i] = matrix[j][i],matrix[i][j]
# 水平互换
for i in range(N):
matrix[i][:] = matrix[i][::-1]

这里我们可以总结:

  • 顺时针旋转90°,先对角翻转,再水平翻转
  • 逆时针旋转90°,先水平翻转,再对角线翻转

零矩阵

数组和字符串 - LeetBook - 力扣(LeetCode)全球极客挚爱的技术成长平台

数字0所在的行与列,在矩阵中全部置0

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def setZeroes(self, matrix:List[List[int]]):
M=len(matrix)
N=len(matrix[0])
line=[0]*M
row=[0]*N
for i in range(M):
for j in range(N):
if matrix[i][j]==0:
line[i] = 1
row[j] = 1
for i in range(M):
for j in range(N):
if line[i]==1 or row[j]==1:
matrix[i][j]=0

这个方法很直观,分别创建行和列的索引,并进行标记,当匹配到标记时,将数据置0

对角线遍历

数组和字符串 - LeetBook - 力扣(LeetCode)全球极客挚爱的技术成长平台

给你一个大小mxn的矩阵mat,以对角线遍历的顺序,用一个数组返回

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class Solution(object):
def findDiagonalOrder(self, mat:List[List[int]])->List[int]:
ans = []
Xsize = len(mat)
Ysize = len(mat[0])
tot = Xsize + Ysize - 1
x,y = 0,0
for i in range(tot):
if i%2==1:
while y>=0 and x<Xsize:
ans.append (mat[x][y])
x += 1
y -= 1
if x<Xsize:
y += 1
else:
y += 2
x -= 1
else:
while x>=0 and y<Ysize:
ans.append (mat[x][y])
x -= 1
y += 1
if y<Ysize:
x += 1
else:
x += 2
y -= 1
return ans

这道题目难在边界条件的处理,需要仔细分析(分类讨论)

注意:在观察二维数组时,一定要把索引与行列之间的关系理清除

这里附上一个很好的解答498. 对角线遍历 - 力扣(LeetCode)

久仰CE修改器大名,今天来学习一下CE的基础使用(官方教程)

我看网上有很多图文教程,在此就尽量不配图。主要记录学习过程中的感受心得为主

  1. 打开进程

​ 这一步没啥难度,不做说明

  1. 已知数值查找

​ 先在此附上官方说明

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
步骤 2: 精确值扫描 (密码=090453)

现在你已经在 Cheat Engine 中打开了训练程序,为我们下一步的练习做好了准备。
本窗口的左下方显示的"健康:XXX",
在你每次点击"打我"按钮时,它的值便会减少。
要进入下一关,你必须找到这个数值并把它改成 1000 。
很多方法都可以找到这个数值的位置,但我将告诉你一个最简单的方法,"精确数值"扫描:
首先确认数值类型设置为2字节或4字节,设置成1字节也可以的,不过最终修改数据的时候便会有点麻烦了(虽然说这是很容易解决的问题)。假如该地址后边的字节数值都为 0 ,
那么你设置成 8 字节也未尝不可,
在这我们就不必尝试了。单浮点数,双浮点数,以及其他的扫描方法在这里行不通的,因为它们储存数值的方式不同。
当数值类型设置正确后,确认扫描类型设置了"精确数值",把健康值填写在数值的输入框,并点击"首次扫描",稍等一会儿(假设你的电脑非常的慢),扫描完毕,扫描的结果将会显示在主界面的左侧。
如果检索结果多于一个,你无法确定哪一个是正确的地址,那么继续点击"打我",并将变更后的"健康值"填写在数值输入框中,点击"再次扫描",重复这些步骤,直到你能确认已经找到了地址(在地址列表中只有一个地址)。
好,双击左侧列表中的地址,该地址便会移动到下方的地址列表中并显示它的当前数值。r
双击下方地址列表中的数值(或者选择它,按下回车),填写你要修改的数值:1000 。
如果操作正确,"下一步"按钮将变成可点击状态,本关就完成了。

提示:
如果你在扫描过程中出现了错误,可以点击"新的扫描"重新再来。当然,你也可以点击"打我"去查找一些更有价值的线索。
  • 扫描类型设置为“精确数值”,并设置初始数值为100,使用首次扫描
  • “打我”之后血量减少,再次扫描新的数值,找到设置血量的指针
  • 双击装载下来后,更改其数值为1000

总结:有时候会遇到无法直接检索到地址的情况(有多个地址),此时反复几次即可,如果始终没有成功检索到,思考一下扫描的数值类型是否正确。或者多尝试几次?

  1. 未知数值查找

​ 先附上官方说明

1
2
3
4
5
6
7
8
9
10
11
12
步骤 3: 未知的初始值 (密码=419482)

OK, 看来你已经理解了如何利用"精确数值"扫描查找数值了,让我们进行下一步。
在上一关中我们知道初始数值的大小,所以我们可以利用"精确数值"扫描,但本关中仅有一个状态栏,我们并不知道它的初始数值。
我们只知道这个数值在0到500之间,并且每次点击"打我"之后便会减些健康值,每次减少的健康值会显示在进度条的上方。
同样有好几种方法可以找这个数值,(例如使用"数值减少了..."扫描方式),但我只教你最简单的方法,"未知的初始值"和"减少的数值"。
由于不知道当前数值的大小,"精确数值"扫描便派不上了用场,所以选择扫描方式"未知初始数值"。数值类型仍然选择 4 字节(这是因为大多数WINDOWS应用程序都使用 4 字节存放数据)。点击"首次扫描"并等待扫描结束。
扫描完成后,点击"打我",你会减少一些健康值。(减少的健康值显示几秒便会消失,你并不需要刻意记下它)。
回到 Cheat Engine,在扫描类型中选择"减少的数值",然后点击"再次扫描"。
扫描完毕后,再次点击"打我",并重复上述步骤,直到检索出很少的几个地址。
我们已经知道这个数值在0到500之间,所以挑出那个最为相似的地址,并将它加到下方的地址列表。
现在,更改健康值为 5000,以便我们进入到下一关。

由于是未知的初始值,我们无法像常规的做法一样。所以搜索的步骤如下:

  • 扫描类型设置为”未知的初始值”,然后进行首次扫描,这个时候是检索不到内容的
  • 此时选择”减少的数值”,然后再次扫描,此时会出现很多未知的地址,多重复几次,可以看到几个有可能的数值
  • 我们将其选中并修改参数为5000即可’

总结:知道变化值的使用”减少了…“或者”增加了…“,不知道变化值的使用”减少的数值”或者”增加的数值”

  1. 浮点数

​ 先附上官方说明

1
2
3
4
5
6
7
8
9
10
11
步骤 4: 浮点数 (密码=890124)

在前面的教程中我们使用字节的方式进行扫描,但有些游戏使用了"浮点数"来存储数值(这么做是为了给菜鸟制造一些麻烦,让他们没那么容易修改游戏)。
浮点数是带有小数点的数值(如 5.12 或 11321.1)。
正如本关中的健康和弹药,两者都以浮点方法储存数据,不同的是,健康值为单精度浮点数,而弹药值为双精度浮点数。
点击"打我"将减少一些健康值,而点击"开火"则消耗掉 0.5 的弹药。
你得把这两项都修改到 5000 或者更多才能过关。
"精确数值"扫描的方式虽然也可以完成本关的工作,但你应该试试其它更简练的扫描方式。


提示: 扫描双浮点数类型建议禁用 "快速扫描"

首先要确定一个数据是单精度浮点数还是双精度浮点数,不过这里给了数据所以正常搜索即可

总结:没什么好总结的哦

  1. 代码查找

·先附上官方说明

1
2
3
4
5
6
7
8
9
10
11
12
13
步骤 5: 代码查找 (密码=888899)

某些游戏重新开始时,数据会存储在与上次不同的地方, 甚至游戏的过程中数据的存储位置也会变动。在这种情况下,你还是可以简单几步搞定它。
这次我将尽量阐述如何运用"代码查找"功能。
下方的数值每次启动教程的时候都会存放在内存不同的位置,所以地址列表中的固定地址是不起作用的。
我们要先找到这个数值当前的存储地址(要如何去做,相信不用我再啰嗦了)。
当你找到了地址就添加在下方的地址列表中,然后右健单击该地址,在弹出的菜单中选择"找出是什么改写了这个地址",将弹出一个空白的窗口。
接着点击本教程窗体上的"改变数值"按钮,并返回 Cheat Engine 。如果操作没问题 在刚才弹出的空白窗口中会出现一些汇编代码。
选中代码并点击"替换"按钮,将它替换成什么也不做的代码(空指令),同时,修改后的代码也将放置在"高级选项"的代码列表中去(保存地址列表时会同时保存)。
点击"停止",游戏会以正常的方式继续运行下去,点击"关闭"按钮,关掉窗口。
现在,再次点击教程窗口上的"改变数值",没问题的话,"下一步"将变为可点击的状态。

提示:如果你以足够快的速度锁定住该地址,"下一步"按钮也会变为可点击的。

这里改变数值实际上是通过汇编指令来实现对内存地址存储的修改:

实际上我们的代码查找步骤可以分解成以下几个动作:

  • 找到存储此数值的当前地址
  • 但我们使用“找出什么改写了这个地址”时,启动一个监视器,记录修改了该地址的汇编指令
  • 将修改数值的汇编捕获后修改为 nop从而实现锁定数值的效果

总结:在游戏中许多数据的存储地址并不是固定的,而是通过动态分配实现的,也就是说传统的”固定地址”修改方法时不起作用的。这个时候衍生一个新的思路,我们不对数据进行修改,而是对改变数据的程序进行修改,这也是一种曲线救国的思路。

  1. 指针

​ 先附上官方说明文档

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
步骤 6: 指针: (密码=098712)

上一步阐述了如何使用"代码查找"功能对付变化位置的数据地址,但这种方法往往不能达到预期的效果,
所以我们需要学习如何利用指针。
在本关的 Tutorial.exe 窗口下面有两个按钮,一个会改变数值,另一个不但能改变数值而且还会改变数值在内存中存储的位置。
这一步,你不需要懂得汇编,但如果懂的话会很有帮助。
首先找到数值的地址,然后再查找是什么改写了这个地址。
再次改变数值,CE 便可以列出找到的汇编代码。 双击一行汇编代码(或选择它并点击"详细信息")并打开"详细信息"窗口以显示详细的信息,用来告诉你当这个指令运行时发生了什么事情。
如果在这条汇编指令中没看到方括号([])的存在,我们就应该查看下一条汇编代码的详细信息,
如果看到了方括号,那很可能表示我们已经找到了需要的指针。
返回到主 cheat engine 窗口 (只要你愿意,你可以保持这个额外的信息窗口为打开状态。如果你要关掉它,那么要记好方栝号中间的代码)并做一次 4 字节的扫描,扫描"详细信息"窗口中告诉你的一串十六进制数值。
扫描完成时它可能返回一个或几百个地址。大多数时候你需要的地址将是最少的一个。现在点击"手工添加地址"按钮,并勾选"指针"选项。
"添加地址"窗口将发生变化,多出了"Address of Pointer(指针地址)"和"Offset (Hex)(偏移量(16进制))"的文本框,以便您键入一个指针的地址和偏移量。
请尽量填入刚才扫描到的地址。
如果汇编指令中的方栝号里存在计算(例如:[esi+12])则把数值部分填在"Offset (Hex)"的文本框中,如果不存在,则让它保持为 0 。
如果看上去是更复杂的计算指令的话(举例说明一下):
[EAX*2+EDX+00000310] eax=4C 并且 edx=00801234.
这种情况下 EDX 便是数值的指针,而 EAX*2+00000310 则是它的偏移量, 所以你要填在"Offset (Hex)"的将是 2*4C+00000310=3A8。(这些都是在十六进制下计算的,你可以使用WINDOWS的计算器,在科学方式下用十六进制计算)。
回到教程,点击"确定"这个地址便会加到 CE 主窗口下方的地址列表中,如果没做错,在地址栏将显示 P->xxxxxxxx,而 xxxxxxxx 和你扫描到的地址数值是一致的,如果不一致,那么可能是哪里出错了。
现在, 改变那条指针地址的数值为 5000 并锁定它,然后点击 Tutorial.exe 窗口上的"改变指针"按钮,如果一切正确,"下一步"按钮将变为可点击状态。

备注:
你也可以使用"指针扫描"的方式来查找这个指针地址。

一开始对着教程做,怎么也理解不了这个过程,以及为什么要这样子做。这里我们结合C语言来加以理解:

1
2
3
4
5
6
7
8
9
int ** pp;
int *p;
int q;
// 数据一般情况下被动态分配
//此时假设p为数据存储的位置,也是p的数据被动态分配,导致数据的位置不确定
p = &q;
//但是我们能找到一个二级指针来指向p的位置,pp自身的位置是硬编码,并不会改变
pp = &p;
//也就是说只要我们能找到二级指针,就能根据指向,取到q中的数据

在这里,我们将二级指针的地址称之为数据的基址,我们可以通过设置基址和偏移量来实现一个指向数据的指针。

在这一关中,我们需要进行以下操作:

  • 确定数据当前的地址
  • 记录修改了此地址的汇编指令,并定位一级指针的基址
  • 设置指针指向当前的数据,手动添加地址

思考:在我们手动设置地址时,添加偏移地址有什么用?

指针存储的是另一个变量的内存地址。通过指针和偏移地址,可以间接访问目标变量。偏移量用于从指针指向的基地址出发,找到目标变量的具体位置。同时这样也可以增强代码的可移植性,因为绝对地址在内存分布中可能会发生改变,但是相对地址是不变的。使用偏移地址,是内存管理的一种智慧

  1. 代码注入

​ 附上官方说明文档

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
步骤 7: 代码注入: (密码=013370)

代码注入是将一小段你写出的代码注入到目标进程中并执行它的技巧。
在这一步教程中,你将有一个健康值和一个每按一次将减少 1 点健康值的按钮,
你的任务是利用"代码注入",使每按一次按钮增加2点的健康值。
查找这个地址,然后看看是什么在改写它("找出是什么改写了这个地址")。
当你看到那条减少数值的汇编代码后,选择"显示反汇编程序",然后打开"自动汇编窗口"(菜单-工具->自动汇编 或 按下快捷键 Ctrl+a ),选择"模板"中的"代码注入"。CE 将自动生成一部分汇编代码并为你输入指令做好准备(如果 CE 没有给出正确的地址,你也可以手工输入它)。
注意 alloc 这部分代码,它会为你的代码分配出一小块空白的内存,过去,在 Win2000 之前的系统,这种行为存在安全隐患,很可能导致系统崩溃,幸运的是,这种情况在 win2000 以后的操作系统得到改善。
也要注意line newmem: 、originalcode: 以及用文本"此处放置你的代码"标示出的空白部分
正如你猜测的, 在这儿可以写下每次增加2点健康值的代码。
在这种情况下推荐你使用 "ADD" 汇编指令,
下面是一些示例:
"ADD [00901234],9" 使 [00901234] 地址的值增加9
"ADD [ESP+4],9" 使地址指针 [ESP+4] 的值增加9
在本关的情况下,你可以使用相同的手法处理减少健康值的那条原代码方括号之间的部分。

提示 1:
推荐你从原代码中删除减少健康值的那行代码,否则你得加 3 点健康值(你增加了3点,原代码减去1点,最终结果才会增加2点),这样看上去很容易让人迷惑,但最终方案还是由你来决定好了。
提示 2:
某些游戏中,原代码可能在多条指令之外,有时候(并非一向如此),它可能由不同的地方跳转至你的指令中并结束运行,其结果可能引起未知的错误;如果出现了这种情况,通常应当查看附近的那些跳转指令,进行修改,或者尝试使用不同地址进行代码注入,确认无误后便可以将你修改的代码注入到原代码中了。

这个原理很简单:

  • 显示监视到修改数据的汇编程序,然后通过反汇编程序找到修改数据的程序
  • 通过 菜单-工具->自动汇编->代码注入的方式来对源程序进行修改

思考:代码注入在汇编层面是怎么实现的?

我一开始以为代码注入,是对源程序进行覆盖,但是这样是不安全的行为。仔细观察后发现,在注入代码后,原来的地址被替换为了一个跳转指令。然后在未使用的空间注入我们修改的代码,然后在使用后跳转回来,也就是说,本质上这是一个子程序调用的过程。那么如果我们只需要修改一行命令或者将其nop掉,我们只需要使用 替换即可,对于多行注入才使用 代码注入

  1. 多级指针

​ 附上官方说明文档

1
2
3
4
5
6
7
8
9
10
11
12
13
14
步骤 8: 多级指针: (密码=525927)

在这一步将解释如何使用多级指针。
在第 6 步,你已经清楚 1 级指针的概念和用途,并可以利用数值的首个地址找到存放数据真正的基址。
在本关中,你将看到 4 级指针,它由第一个指针指向第二个指针,再由第二个指针指向第三个指针,由第三个指针指向第四个指针,最终指向健康值的真正地址。
开始的几步与在第 6 步中的操作基本相同。找出是什么访问了这个地址,然后分析汇编指令,查找指针地址中的数值,以及它的偏移量,将它们记下来。但这次你按数值找出的仍然是一个指针,你得依据这些数值,使用同样的操作方法找出指向这个指针的指针。看看是什么访问了你发现的那个指针地址,分析汇编指令,留意可能的代码和偏移量,并加以利用。
持续这种过程,直到不能更进一步查找为止(通常基址为静态时,地址将以绿色标示)。
点击"改变数值"改变健康值,
如果你发现列表中那些指针地址所指向的值发生同样的变化时,那表示你可以试着将基址中的值更改为 5000,并锁定它,以便完成本关的任务了。

备注1: 本步骤也可以使用自动汇编程序脚本或者使用指针扫描器加以解决。
备注2: 在某些情况下,可以改变 CE 软件"代码查找"的相关设置。
当你遇到类似于 mov eax,[eax] 的指令时,调试程序将显示改变之后的寄存器中的值,也许利用它更容易找出指针的位置。
备注3: 你还在读?!当你查看汇编指令时你可能已经注意到,这些指针是在相同的代码块(相同的程序,如果你懂汇编,可以查看程序的起始代码)位置被读写。这种情况并不总会发生,但是当你在查找某个指针遇到问题的时候,没准能起到很大的用处。

本来想试试指针扫描器的,但感觉有点复杂,所以用第六关一样的方法向上搜索这不过这一次的复杂一点,其步骤如下:

  • 首先定位数据,然后查找指向这个数据的指针
  • 在详细信息中,我们可以看到一级指针的地址和偏移地址,然后我们创建一个一级指针
  • 然后我们在查找执行一级指针地址的指针…
  • 如此往复,直到找到数据的基址

总结:一个数据可能由多级指针定位,我们可以任然可以通过寻找基址的方法定位这个指针链。这个需要耐心,而且操作的过程需要清楚自己在做什么(从1号柜子拿到2号柜子的钥匙,打开2号柜子拿到3号柜子的钥匙…)

  1. 注入 ++

​ 附上官方文档

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
步骤 9: 注入++: (密码=31337157)

这一步将会解释如何处理游戏中的共用代码, 这种代码是通用在除了自己以外的其他同类型对像上

常常你在修改游戏的时候, 你找到了一个单位的健康, 或是你自己角色的健康, 你会发现一种情况: 如果你把健康相关代码移除的话,其结果是你的角色无敌, 但你的敌人也无敌了。
在这种情况下, 你必须想办法区分自己与敌人。
有时候很简单, 你只要检查最前面的4个字节(函数指针表), 它通常指向一个独一无二的地址, 代表着游戏玩家角色,而有的时候它是一个团体号码, 或者也可能是一个指针, 它指向另一个指针, 该址针又指向下一个指针,搞不好还指向下下一个指针, 最后指向一个玩家名字。总之完全取决于游戏的复杂度, 以及你的运气

最简单的方法是以"找出是什么改写了这个地址"去找出游戏代码,然后使用"分析(新/旧)数据/结构"的功能去比较两种结构。(你的单位和敌人的单位)然后看看是不是可以找到一个区分两者的方法。
当你找到如何区分你和电脑单位的方法后,你可以注入一段自动汇编脚本来检查状态,然后看是要运行游戏的代码还是要做其他的修改。(例如一击必杀)
另外, 你还可以用这个方法去创建一般所说的"字节数组"的字串, 它可以用来搜寻并产生一份所有你的单位或是敌人单位的列表
在这个教程中, 我已经实现了你将会玩到的最惊人的游戏.
这个游戏有4个玩家。2个属于你的阵容, 另外两个属于电脑方。
你的任务是找到改写健康的代码, 并且修改以至于你可以获得胜利,但"绝不能"使用锁定HP的方法.
完成修改以后, 请按 "重新启动游戏并自动执行" 来测试你的修改是否正确

提示1: 健康是一个单浮点数
提示2: 解法不只一种

这个是一个应用题,所以就详细讲讲过程

  • 首先定位 四个人物血量的存储地址
  • 然后追踪发现这四个任务的血量修改的程序都是同一个(无差别),可是我们希望能攻击敌人却不攻击自己,所以我们需要找到同伴与敌人的特征码
  • 接下来 找出指令访问的地址 然后 打开选中地址分析数据,我们可以比较这四个数据的不同点,观察发现在偏移地址位+10的地方有着识别码,而在+04的偏移地址存放着血量
  • 接下来我们需要代码注入,当识别为同伴时恢复至满血,当识别为敌人时直接杀死
  • 然后重启程序并执行即可

书接上回 这一篇主要讲一下,当一个C语言函数在执行时,操作系统是如何调度内存将数据存放并完成相关函数操作的

C语言内存分布

image.png

当一个C语言程序被编译成可执行文件执行时,它在内存中的存储如右图所示。这是一个内存空间,地址由底部逐渐升高:

  • 其中顶层的Kernel是操作代码的核心源码,它是操作系统完成功能的关键
  • 栈用于静态分配中的存放局部变量,例如程序中的局部变量t和ptr都被存储在栈中
  • BSS存储未初始化的全局变量和静态变量
  • Heap(堆)用于负责存储动态分配的内存空间比如malloc和free操作的内存空间
  • data用于存储已经进行了初始化的全局变量
  • 在heap和stack中间的内存空间,是一片共享的内存空间,heao从低地址向高地址分配空间,stack从高地址向低地址分配

栈中的内存分配与工作原理

现在分析一下函数调用时,栈的内存空间中是如何分配的

要了解栈,首先需要理解栈中常用的几个寄存器:

  • 在32位中,我们用esp,ebp,eip三个寄存器
  • 在64位中,我们用rsp,rbp,rip三个寄存器

接着我们需要了解一下栈帧的概念,一个栈帧就是保存一个函数的状态,简单地说,就是一个函数所需要的栈空间。 sp永远指向栈帧的栈顶, bp永远指向栈帧的栈底, ip则指向当前栈栈帧执行的命令。

image.png

我们现在可以分析一下函数调用的过程,栈底的第一个栈帧存储着我们的主函数的父函数,所以说main实际上并不是第一个栈帧,在main之前还有一些编译过程中产生的库文件,只不过不产生栈帧。

当我们在main中调用其他函数时,我们便在栈中开辟一块新的栈帧,并在其中存储上一个栈的栈底,当函数调用结束时,我们就将现在的栈帧弹出,恢复到原来的main函数继续执行完main函数。

那么,至此我们需要进一步的对栈帧的具体结构进行讲解。首先是栈帧的创建:

image.png
image.png
image.png
image.png

以上是一个栈帧建立的过程,我们注意到新栈帧底部的返回地址。这是栈溢出使用的关键,这个返回地址的实际作用是在当前栈帧结束后,弹出至ip,实现函数返回。而在栈溢出的攻击中,我们可以通过覆盖/修改返回地址的内容,使其指向我们想要的后门函数。这就是栈溢出攻击的原理

我们接下来进一步看看栈帧的返回过程:

image.png
img
img

这里我们需要注意返回地址实际上是调用函数指令的下一条,并不是我们理解的返回函数入口。

在这些PPT中我们可以感受到一个函数返回的过程:

  • 首先是撤销我们的栈帧中的局部变量,我们直接弹出即可
  • 然后我们将原函数的栈底弹出,恢复原来栈底
  • 再将返回地址弹出,让函数继续执行

这样的一个过程便是函数调用与返回,通过图片的形式展现出来。

栈溢出攻击

在学习了函数返回的原理之后,我们明确,只要想办法修改返回地址即可实现攻击

那么我们在哪一步中有机会实现这个操作呢?

image.png

我们可以通过将局部变量溢出来实现攻击,但是并非所有的情况下都能实现攻击。不过当程序中使用了 gets的有安全隐患的函数时,因其并没有设置边界,理论上是可以栈溢出至返回地址,并将其覆盖的。

我们可以写出常见的攻击脚本:

1
2
3
4
5
6
from pwn import *
p = remote("xxxx",端口)
payload="a"*(局部变量的长度)+"a"*(栈底的长度)+p64(你想要的返回地址)
# 注意 这里的长度随机器变化,32位与64位不同
p.sendline(payload)
p.interactive()