C# C++ 笔记
第一阶段知识总结
lunix系统操作
1、基础命令
(1)cd
cd /[目录名] 打开指定文件目录
-
cd .. 返回上一级目录
-
cd - 返回并显示上一次目录
-
cd ~ 切换到当前用户的家目录
(2)pwd
pwd 查看当前所在目录路径
-
pwd -L 打印当前物理路径
(3)ls
ls 查看当前目录下的文件和目录
注:部分系统文件和目录会用不同颜色显示
-
ls [目录名] : 显示目录下的文件(需要有查看目标目录的权限)
-
ls -a: 显示全部文件(包括文件名以“.”开头的隐藏文件)
-
ls -alh
-
ls -alh 查看时显示详细信息
-
d 指该内容为目录
-
x 指该内容为可执行
-
r 指可读
-
w 指可写
-
. 指当前目录
-
.. 指上一级目录
-
(4)touch
-
touch [文件名] 新建文件
(5)rm
-
rm [文件名] : 删除文件
-
r 强制删除
-
f 允许删除目录
-
(6)mv
-
mv [文件原名] [新的文件名] 重命名文件|目录
(7)mkdir
-
mkdir [目录名] 新建目录
-
mkdir -p [目录名]/[目录名]/…… 将不存在的目录全部创建
-
mkdir -v [目录名] 创建时打印目录信息
-
mkdir -m [目录名] 设置目录权限
-
(8)rmdir
-
rmdir [目录名] 删除目录(只能删除空目录)
-
rmdir /s [目录名] 删除非空目录
-
(9)cp
-
cp [被复制的文件名] [新的文件名] 复制文件|目录
-
cp -i [被复制的文件名] [新的文件名] 若新文件重名系统会询问是否覆盖,默认覆盖
-
cp -n [被复制的文件名] [新的文件名] 若新文件重名系统不会覆盖
-
cp -u [被复制的文件名] [新的文件名] 若新文件重名,只有被复制的文件的时间属性比重名文件新的时候才覆盖
-
cp -p [被复制的文件名] [新的文件名] 连同文件属性一起复制,默认只复制内容
-
(10)vi
-
vi [文件名] 编写文件
-
按“i”键后可以开始修改内容,按之前只有delete键有效,按之后delete失效,backspace有效
编写时按“Esc”键退出编写,之后按“shift”+“:”输入命令
-
w filename 保存
-
wq 保存并退出
-
q! 不保存强制退出
-
x 执行、保存并退出
(11)cat
-
cat [文件名] 读取文件
(12)echo
-
echo “[输入内容]” > [文件名] 清空文件并输入内容
(13)chmod
-
chmod
是 Unix 和 Linux 系统中的命令,用于更改文件或目录的权限。权限定义了哪些用户可以对文件或目录执行哪些操作。 -
chmod
命令的基本语法如下:
chmod [选项] 模式 文件名
-
使用符号模式,你可以通过添加(
+
)、删除(-
)或设置(=
)权限来修改文件或目录的权限。权限符号可以是r
(读)、w
(写)或x
(执行)。
(14)man
-
在使用
man
命令时,可以在命令后面加上一个数字,以指定查看哪个手册页面节(man page section)。这个数字告诉系统应该搜索哪个手册页面的部分,因为一个命令或函数可能在不同的上下文中有多个手册页面。在 Unix 和类 Unix 系统中,手册页一般被分成以下几个节(sections): -
General commands (通用命令):主要包含系统管理员和普通用户可以使用的命令。
-
例如:
man 1 printf
可以查看printf
命令的手册页面。
-
-
System calls (系统调用):这些是操作系统提供的服务和功能的编程接口。
-
例如:
man 2 open
可以查看open
系统调用的手册页面。
-
-
Library functions (库函数):包括标准 C 库和其他库的函数。
-
例如:
man 3 strlen
可以查看strlen
函数的手册页面。
-
-
Special files (特殊文件):通常是设备文件和文件系统。
-
例如:
man 4 tty
可以查看关于tty
特殊文件的手册页面。
-
-
File formats and conventions (文件格式和约定):包括配置文件和文件格式的描述。
-
例如:
man 5 passwd
可以查看passwd
文件的手册页面。
-
-
Games (游戏):关于游戏的手册页面。
-
例如:
man 6 tetris
可以查看关于tetris
游戏的手册页面。
-
-
Miscellaneous (杂项):其他的手册页面。
-
例如:
man 7 regex
可以查看关于正则表达式的手册页面。
-
-
System administration commands (系统管理命令):主要用于系统管理员的命令和工具。
-
例如:
man 8 iptables
可以查看关于iptables
命令的手册页面。
-
2、C语言环境下的相关命令
(1)gcc
gcc [文件名] 编译C语言文件生成a.out执行文件 gcc -g [文件名] 编译C语言文件生成可调试的a.out执行文件
(2)./
./[文件名] 运行文件
注:不需要空格
3、DOS通用注意点
(1)--help
-
“命令 --help”为该命令的帮助文档
(2)sudo
-
sudo [指令] 以管理员身份执行某一指令(需输入密码)
-
sudo passwd root 修改管理员用户密码且之后带sudo的命令不需要再输入密码
-
su 登陆管理员账户
4、makefile
(1)编译步骤及原理
-
1、预编译
-
gcc -E [文件名.c] -> [预处理文件名.i],编译前的一些工作,生成.i文件
-
作用:展开头文件;宏替换;去掉注释;条件编译。
-
.i文件是用于c语言的,.ii是用于c++语言
-
-
2、编译
-
gcc -S [预处理文件名.i],生成对应的汇编文件,生成.s文件
-
作用:将代码转成汇编代码。
-
-
3、汇编
-
gcc -c [编译文件名.s],生成对应的二进制文件,生成.o文件
-
作用:将汇编代码转成机器码
-
-
4、链接
-
gcc [汇编文件名.o]
-
作用: 将所有的.o文件链接成a.out文件。
-
(2)makefile核心目的
-
makefile文件指导系统如何编辑,节省大项目局部修改后的编译时间。局部修改后之后修改处需要重新编译。
-
如果一个文件中有makefile和Makefile文件,在命令行输入make,优先执行makefile。如果执行大写的Makefile,需要加上-f:make -f Makefile。
(3)makefile运行逻辑
(4)makefile基本语法
#[目标]:[依赖1] [依赖2] …
#[命令1] [命令2] …
#例如
main:main.o
gcc main.o -o main
-
目标: 一般是指要编译的目标,也可以是一个动作
-
依赖: 指执行当前目标所要依赖的选项。包括其他目标,某个具体文件或库等,一个目标可以有多个依赖。
-
命令:该目标下要执行的具体命令,可以没有,也可以有多条。
(5)makefile编译流程
main:main.o myAdd.o myMinus.o myMulti.o myDiv.o
gcc main.o myAdd.o myMinus.o myMulti.o myDiv.o -o main
# -c 生成二进制文件 -o 指定输出的文件名
myAdd.o:myAdd.c
gcc -c myAdd.c -o myAdd.o
myMinus.o:myMinus.c
gcc -c myMinus.c -o myMinus.o
myMulti.o:myMulti.c
gcc -c myMulti.c -o myMulti.o
myDiv.o:myDiv.c
gcc -c myDiv.c -o myDiv.o
main.o:main.c
gcc -c main.c -o main.o
clean:
@rm -rf *.o main
注:@
表示执行但不输出这条命令
在终端输入 make 从上至下后执行文件命令 输入 make [目标] 仅执行对应命令
(6)makefile变量及文件精简过程
6.1 $@
# 针对上一张的图片,用$(CC)替换gcc命令 main:main.o myAdd.o myDiv.o myMinus.o myMulti.o $(CC) $^ -o $@ myAdd.o:myAdd.c $(CC) -c $^ -o $@ myMinus.o:myMinus.c $(CC) -c $^ -o $@ myMulti.o:myMulti.c $(CC) -c $^ -o $@ myDiv.o:myDiv.c $(CC) -c $^ -o $@ main.o:main.c $(CC) -c $^ -o $@ # 用$(RM)替换rm命令 clean: @$(RM) *.o main
6.4 自定义常量
# 变量自定义赋值 OBJS=main.o myAdd.o myDiv.o myMinus.o myMulti.o TARGET=main # 变量取值用$() $(TARGET):$(OBJS) $(CC) $^ -o $@ myAdd.o:myAdd.c $(CC) -c $^ -o $@ myMinus.o:myMinus.c $(CC) -c $^ -o $@ myMulti.o:myMulti.c $(CC) -c $^ -o $@ myDiv.o:myDiv.c $(CC) -c $^ -o $@ main.o:main.c $(CC) -c $^ -o $@ clean: @$(RM) *.o $(TARGET)
-
表示目标文件的完整名称。
-
# 针对上一小节的图片,用$@替换目标文件 main:main.o myAdd.o myDiv.o myMinus.o myMulti.o gcc main.o myAdd.o myDiv.o myMinus.o myMulti.o -o $@ myAdd.o:myAdd.c gcc -c myAdd.c -o $@ myMinus.o:myMinus.c gcc -c myMinus.c -o $@ myMulti.o:myMulti.c gcc -c myMulti.c -o $@ myDiv.o:myDiv.c gcc -c myDiv.c -o $@ main.o:main.c gcc -c main.c -o $@ clean: @rm -rf *.o main
6.2
$^
-
表示所有不重复的依赖文件
-
# 针对上一张的图片,用$^替换依赖文件 main:main.o myAdd.o myDiv.o myMinus.o myMulti.o gcc $^ -o $@ myAdd.o:myAdd.c gcc -c $^ -o $@ myMinus.o:myMinus.c gcc -c $^ -o $@ myMulti.o:myMulti.c gcc -c $^ -o $@ myDiv.o:myDiv.c gcc -c $^ -o $@ main.o:main.c gcc -c $^ -o $@ clean: @rm -rf *.o main
6.3 系统常量
-
RM
:删除 -
CC
:C语言编译程序 -
[常量名]=[值]
赋予自定义常量值 -
$()
取自定义变量的值 - 编辑
6.5 makefile伪目标
-
.PHONY: [目标]
当只想执行目标命令而不希望生成目标文件时使用 -
注:
:
后有个空格
OBJS=main.o myAdd.o myDiv.o myMinus.o myMulti.o
TARGET=main
$(TARGET):$(OBJS)
$(CC) $^ -o $@
myAdd.o:myAdd.c
$(CC) -c $^ -o $@
myMinus.o:myMinus.c
$(CC) -c $^ -o $@
myMulti.o:myMulti.c
$(CC) -c $^ -o $@
myDiv.o:myDiv.c
$(CC) -c $^ -o $@
main.o:main.c
$(CC) -c $^ -o $@
# 伪目标(伪文件),指执行命令,不生成文件
.PHONY: clean
clean:
@$(RM) *.o main
6.6 模式匹配
-
%[目标]:%[依赖]
匹配目录下所有符合命令的文件,批量执行命令
OBJS=$(patssubst %.c, %.o, $(wildcard ./*.c))
TARGET=main
$(TARGET):$(OBJS)
$(CC) $^ -o $@
# 模式匹配 %[目标]:%[依赖]
%.o:%.c
$(CC) -c $^ -o $@
.PHONY: clean
clean:
@$(RM) *.o main
6.7 总代码
OBJS=$(patsubst %.c, %.o, $(wildcard ./*.c)) # 变量定义赋值 TARGET=main LDFLAGS=-L./src_so -L./src_a LIBS=-lMyAdd -lMyDiv SO_DIR=./src_so A_DIR=./src_a #变量取值用$() $(TARGET):$(OBJS) $(CC) $^ $(LIBS) $(LDFLAGS) -o $@ # 模式匹配: %目标:%依赖 %.o:%.c $(CC) -c $^ -o $@ all: make -C $(SO_DIR) make -C $(A_DIR) # 伪目标/伪文件 .PHONY: clean clean: $(RM) $(OBJS) $(TARGET) make -C $(SO_DIR) clean make -C $(A_DIR) clean # wildcard : 匹配文件 (获取指定目录下所有的.c文件) # patsubst : 模式匹配与替换 (指定目录下所有的.c文件替换成.o文件) show: @echo $(wildcard ./*.c) @echo $(patsubst %.c, %.o, $(wildcard ./*.c))
(7)makefile动态库与静态库
7.1 动态库
-
作用:用于打包整合所有的 .c 源文件,同时使用者也无法通过动态库还原代码
-
注:windows 中是 .dll 文件;linux 中是 .so 文件
-
1、生成 .o 二进制文件
-
gcc -c -fPIC myAdd.c -o myadd.o
-
-
- 2、生成动态库 - `gcc -shared myAdd.o -o libMyAdd.so` - **可放在一起,**`gcc -shared -fPIC myAdd.c -o libMyAdd.so`
-
使用动态库:
-
gcc -ImyAdd -L./src.so -o main
-
libMyAdd.so由 lib + 函数名 + .so 组成
-
myAdd是函数名
-
src.so是动态库文件目录
-
-
提供给客户的只有libMyAdd.so和MyAdd.h文件
注:上图myadd
中a
没有大写导致报错
-
3、复制到没有 .c 源文件的文件夹下
-
4、生成main.o文件
-
gcc *.c -lMyAdd -L./src_so -o main
-
-
5、运行main.o文件
动态库内部makefile
-
运行前先执行内部makefile生成动态库
-
然后再外部调用动态库
OBJS=$(patsubst %.c, %.o, $(wildcard ./*.c))
TARGET=libMyAdd.so
PATHS=/usr/lib/
$(TARGET):$(OBJS)
$(CC) -shared -fPIC $^ -o $@
cp $(TARGET) $(PATHS)
%.o:%.c
$(CC) -c $^ -o $@
clean:
$(RM) $(OBJS) $(TARGET)
show:
@echo $(RM)
@echo $(OBJS)
7.2 静态库
-
以下是使用静态库的基本步骤:
-
创建静态库:
-
通常,你会有一系列的源文件(
.c
或.cpp
等),这些源文件会被编译成目标文件。然后,这些目标文件会被打包成一个静态库文件。在Linux中,这通常是一个以.a
为扩展名的文件。 -
例如,假设你有两个源文件
file1.c
和file2.c
,你可以这样创建静态库:
gcc -c file1.c -o file1.o gcc -c file2.c -o file2.o ar rcs libmystatic.a file1.o file2.o// rcs 换成 -r 也行
-
这里,
ar
命令用于创建静态库,rcs
是其选项,表示替换现有的库文件(r),创建库文件(c),并且指定库文件的索引(s)。 -
使用静态库:
-
当你有一个或多个源文件需要使用静态库中的代码时,你需要在编译和链接阶段指定这个静态库。链接器会将静态库中的目标文件与你的源文件编译出的目标文件合并,生成最终的可执行文件。
-
-
例如,假设你有一个源文件
main.c
,它调用了静态库libmystatic.a
中的函数。你可以这样编译和链接:
gcc main.c -L. -lmystatic -o myprogram
-
这里,
-L.
告诉链接器在当前目录(.
表示当前目录)中查找库文件,-lmystatic
指定链接到libmystatic.a
库(注意,链接时不需要库文件的前缀lib
和扩展名.a
)。 -
静态库内部makefile
-
运行前先执行内部makefile生成动态库
-
然后再外部调用动态库
-
OBJS=$(patsubst %.c, %.o, $(wildcard ./*.c)) TARGET=libMyDiv.a $(TARGET):$(OBJS) $(AR) -r $(TARGET) $^ # 模式匹配 %.o:%.c $(CC) -c $^ -o $@ clean: $(RM) $(OBJS) $(TARGET)
7.3 动态库与静态库外部makefile
-
采用make -C 命令就可以执行库里面内部的makefile,可以不用先在内部执行生成库文件。
-
先输入make all命令执行all中的代码生成库
-
再输入make
-
或者直接一条命令make all && make。
OBJS=$(patsubst %.c, %.o, $(wildcard ./*.c)) # 变量定义赋值 TARGET=main LDFLAGS=-L./src_so -L./src_a LIBS=-lMyAdd -lMyDiv SO_DIR=./src_so A_DIR=./src_a #变量取值用$() $(TARGET):$(OBJS) $(CC) -g $^ -o $@ # 模式匹配: %目标:%依赖 %.o:%.c $(CC) -g -c $^ -o $@ all: make -C $(SO_DIR) make -C $(A_DIR) # 伪目标/伪文件 .PHONY: clean clean: $(RM) $(OBJS) $(TARGET) # wildcard : 匹配文件 (获取指定目录下所有的.c文件) # patsubst : 模式匹配与替换 (指定目录下所有的.c文件替换成.o文件) show: @echo $(wildcard ./*.c) @echo $(patsubst %.c, %.o, $(wildcard ./*.c))
7.4 区别
-
链接方式:
-
静态库在编译时被链接到程序中,而动态库在运行时被加载到内存中。
-
使用静态库的程序在编译时会将库的内容直接合并到最终的可执行文件中,而使用动态库的程序则会在运行时根据需要从库中加载所需的代码。
-
-
更新和维护:
-
静态库,如果需要更新库中的代码,必须重新编译并重新链接所有依赖于该库的程序。
-
动态库可以独立更新,而不需要重新编译程序,这使得库的维护更加方便。动态库更适合多文件场合。
-
(8)Makefile的常用选项
-
-f file
:指定Makefile文件。默认情况下,make会在当前目录中查找名为GNUmakefile、makefile或Makefile的文件作为输入。使用-f
选项,你可以指定其他名称的文件作为Makefile。 -
-v
:显示make工具的版本号。 -
-n
:只输出命令,但不执行。这个选项通常用于测试Makefile,查看make会执行哪些命令,而不真正执行它们。 -
-s
:只执行命令,但不显示具体命令。这跟makefile中的(@+命令行)符号作用一样。这个选项在需要执行命令但不需要看到详细输出时很有用。 -
-w
:显示执行前和执行后的路径。 -
-C dir
:指定Makefile所在的目录。如果Makefile不在当前目录中,可以使用这个选项来指定Makefile的目录。
(9)makefile中shell的使用
-
所有在命令行输入的命令都是shell命令
9.1直接执行shell命令
-
在Makefile的规则中,直接写shell命令,并在命令前加上
$(shell ...)
或者反引号...
来执行。例如:
FILES = text.txt A=$(shell ls ./) B=$(shell pwd) C=$(shell if [ ! -f $(FILE) ]; then touch $(FILE); fi;) show: @echo $(A) @echo $(B) @echo $(C)
-
在这个例子中,
$(shell ls ./)
会执行ls ./
命令,并将结果文件显示输出以及创建test.txt文件。然后,在show
规则的命令部分,我们使用@echo
来打印这些文件名。
9.2 shell 中 -f 与 -d 指令
-
在Unix和Linux shell中,
-f
和-d
是用于测试文件类型的条件表达式(也称为测试运算符)。这些通常与if
语句或while
循环等控制结构一起使用,以根据文件的存在和类型来执行不同的操作。 -
-f
测试:-
-f
测试用于检查指定的路径是否为一个常规文件(即不是目录、设备文件、符号链接等)。
-
-
示例:
if [ -f /path/to/file ]; then echo "The path is a regular file." else echo "The path is not a regular file." fi
-
-d
测试:-
-d
测试用于检查指定的路径是否为一个目录。
-
-
示例:
if [ -d /path/to/directory ]; then echo "The path is a directory." else echo "The path is not a directory." fi
-
在上面的示例中,如果指定的路径是一个常规文件,那么
-f
测试将返回真(true),并且会执行then
部分的代码。如果指定的路径是一个目录,那么-d
测试将返回真,并执行相应的代码。
(10) makefile条件判断
-
Makefile支持使用条件语句来根据某些条件执行不同的shell命令。这通常使用
ifeq
、ifneq
、ifdef
、ifndef
等指令来实现。例如:
OS = $(shell uname -s) ifeq ($(OS), Linux) CC = gcc else CC = clang endif all: $(CC) -o myprogram myprogram.c
-
在这个例子中,我们首先使用
$(shell uname -s)
来获取操作系统类型,并将其赋值给变量OS
。然后,我们使用ifeq
来判断OS
的值,如果是Linux
,则使用gcc
作为编译器;否则,使用clang
。最后,在all
规则的命令部分,我们使用选定的编译器来编译程序。
(11) makefile命令行参数
(12)Makefile中install
-
源码安装,不通过apt-get install安装。
12.1 功能作用
-
创建目录,将可执行文件拷贝到指定目录(安装目录)
-
加全局可执行的路径
-
加全局的启停脚本
-
cp一行将生成的目标拷贝至该文件路径中
-
sudo一行是软链接
linux设备启动时会将这些文件启动:
12.2 主要目的
-
Makefile中的
install
目标的主要目的是提供一个标准化的方式来安装编译后的程序、库、文档以及其他相关文件到用户的系统上。当开发者构建了一个软件项目后,他们通常希望用户能够轻松地将其安装到他们的系统上,并使其能够正常运行。install
目标就是用来完成这一任务的。 -
具体来说,
install
目标通常会执行以下操作:-
复制文件:将编译后的可执行文件、库文件、头文件等复制到指定的安装目录。这些目录通常是系统级的目录,如
/usr/local/bin
用于存放可执行文件,/usr/local/lib
用于存放库文件等。 -
设置权限:确保复制的文件具有正确的权限,以便用户可以正常访问和使用它们。
-
创建目录:如果需要,
install
目标还可以创建必要的目录结构,以便将文件放置到正确的位置。 -
安装文档:除了程序本身,
install
目标还可能包括安装相关的文档、手册页等。 -
执行其他安装步骤:根据项目的具体需求,
install
目标还可以包含其他必要的安装步骤,如创建配置文件、设置环境变量等。
-
-
可以将文件做成全局的,比如自实现mycp命令,做成全局后,可以在任意位置使用mycp命令
12.3 软链接与硬链接
-
Linux软链接(Symbolic Link)是一种特殊的文件类型,它可以创建一个指向另一个文件或目录的链接。软链接不是实际的文件或目录,而是一个指向实际文件或目录的指针。当我们访问软链接时,实际上是访问被链接的文件或目录。
-
软链接在Linux系统中非常常见,并且被广泛应用于各种场景。其主要特点和应用包括:
-
快速访问文件:当某个文件位于深层次的目录中时,可以通过创建软链接到其他位置来方便快速访问。
-
管理共享库:在Linux系统中,软链接常用于管理共享库。通过创建共享库的软链接,可以实现不同版本之间的切换和共存。
-
创建快捷方式:软链接可以被视为Linux系统中的快捷方式,它允许用户为常用文件或目录创建一个指向它的链接,从而方便快速访问。
-
-
创建软链接的常用方法是使用
ln
命令,具体语法为 “ln -s target source
” ,其中 “target” 表示目标文件(夹),即被指向的文件(夹),而 “source” 表示当前目录的软连接名,即源文件(夹)。 -
通过软链接指令生成的软链接文件mycp,生成的文件属性为软链接
-
实体没有了,那只是快捷方式,因此在使用mycp命令会显示没有该文件
12.4 ln -sv命令
-
ln -sv
命令在 Linux 中用于创建符号链接(软链接)。这里的-s
表示创建软链接,而-v
表示详细模式(verbose),即会显示创建的链接的详细信息。 -
具体解释如下:
-
ln
: 这是链接命令,用于创建链接。 -
-s
: 表示创建软链接(符号链接)。如果不加-s
,那么默认创建的是硬链接。 -
-v
: 详细模式,会显示命令执行过程中的信息,例如正在创建哪个链接。
-
-
例如,假设你有一个文件叫做
original.txt
,并且你想要为它创建一个名为link.txt
的软链接,你可以使用以下命令:-
ln -sv original.txt link.txt
-
-
执行这条命令后,你会看到类似以下的输出:
-
'link.txt' -> 'original.txt'
-
这意味着
link.txt
现在是一个指向original.txt
的软链接。之后,如果你通过link.txt
访问文件,实际上你会访问到original.txt
。
-
12.5 软链接makefile操作
OBJS=$(patsubst %.cpp, %.o, $(wildcard ./*.cpp)) # 变量定义赋值 TARGET=mycp LDFLAGS=-L./src_so -L./src_a LIBS=-lMyAdd -lMyDiv SO_DIR=./src_so A_DIR=./src_a PATHS=/tmp/demoMain/ BIN=/usr/local/bin/ #变量取值用$() $(TARGET):$(OBJS) $(CXX) $^ -o $@ # 模式匹配: %目标:%依赖 %.o:%.cpp @$(CXX) -c $^ -o $@ all: make -C $(SO_DIR) make -C $(A_DIR) install:$(TARGET) @if [ -d $(PATHS) ]; \ then echo $(PATHS) exist; \ else \ mkdir $(PATHS); \ cp $(TARGET) $(PATHS); \ sudo ln -sv $(PATHS)$(TARGET) $(BIN); \ fi # 伪目标/伪文件 .PHONY: clean clean: $(RM) $(OBJS) $(TARGET) make -C $(SO_DIR) clean make -C $(A_DIR) clean # wildcard : 匹配文件 (获取指定目录下所有的.c文件) # patsubst : 模式匹配与替换 (指定目录下所有的.c文件替换成.o文件) show: @echo $(wildcard ./*.cpp) @echo $(patsubst %.cpp, %.o, $(wildcard ./*.cpp))
12.6 软硬链接的区别
软链接与硬链接(Hard Link)有所不同。硬链接是直接指向文件的物理位置。而软链接则是指向文件名的路径,如果原始文件被移动、重命名或删除,软链接将会失效(即所谓的“死链接”)。此外,软链接可以跨越不同的文件系统,而硬链接只能在同一文件系统内使用。
5、Gdb调试
(1)基本调试步骤
-
1、gdb [文件名] 调试文件,需事先 gcc -g .c文件
-
2、gdb a.out
-
3、run
-
4、bt
-
若没有-g就直接编译了,使用gdb就会出现以下信息:没有bug信息
注:若报错 ‘gdb’ not found,输入指令 apt-get install gdb 一直回车即可
(2)常用gdb调试命令
常用指令 | 全称 | 指令效果 |
---|---|---|
b [代码行数] | break | 在第几行添加断点,如果在指定文件打断点,则b 指定文件 : 行号 |
info b | info break | 显示所有断点的信息,一般按自然数排序 |
del [断点编号] | delete | 删除断点,示例:del 56 删除编号为56的断点 |
dis | disable | 禁用断点 |
ena | enable | 启用断点 |
p [变量] | 查询值,包括以下形式,vary,&vary,*ptr,buffer[0] | |
run | / | 执行程序,直到结束或断点 |
n | next | 执行下一条语句,会越过函数 |
s | step | 执行下一条语句,会进入函数 |
c | continue | 继续执行程序,直到遇到下一个断点 |
call | / | 直接调用函数并查看其返回值 |
q | quit | 退出 gdb 当前调试 |
bt | backtrace | 查看函数的栈调用 |
f | frame | 到指定的栈帧,配合bt使用。 显示当前选中的堆栈帧的详细信息包括帧编号、地址、函数名以及源代码位置 |
where | / | 显示当前线程的调用堆栈跟踪信息 |
ptype | / | 查看变量类型 |
thread | / | 切换指定线程 |
set br br | / | 将信息按标准格式输出,这样信息就显示不乱 |
set args | / | 设置程序启动命令行参数 |
show args | / | 查看设置的命令行参数 |
l | list | 显示源代码。 |
watch | / | 监视某一变量或内存地址的值是否发生变化 |
u | until | 运行程序直到退出当前循环。 快速跳过循环的剩余迭代,以便更快地到达循环之后的代码。 |
fi | finish | 执行完当前函数的剩余部分,并停止在调用该函数的地方。 示例:finish 执行完当前函数的剩余部分。 |
return | / | 结束当前调用函数并返回指定值,到上一层函数调用处 |
display | / | 每次程序停止时自动打印变量的值。 示例:display name 每次程序停止时自动打印 name 的值。 |
undisplay | / | 取消之前用 display 命令设置的自动打印。 |
j | jump | 使程序跳转到指定的位置继续执行。 |
dir | / | 重定向源码文件的位置 |
source | / | 读取并执行(加载)一个包含GDB命令的脚本文件 |
set | / | set variable=newvalue,修改变量的值 |
(3)调试详解
3.1 call命令
-
call func:显示地址信息,因为是函数名,指向函数地址。
-
call func( ):无参调用,显示函数的返回值
-
call add(100,200 ):有参调用,返回300。
3.2 gdb attach
-
attach
命令用于将一个正在运行的进程附加到GDB调试器中,以便你可以对该进程进行调试。这对于调试那些已经启动并且你希望动态地分析其行为的进程非常有用。 -
使用
attach
命令的基本语法是:-
gdb attach <进程ID>
-
这里的
<进程ID>
是你想要附加的进程的ID。你可以通过ps - ef
命令或者其他系统工具来获取进程ID。 -
一旦进程被附加到GDB,你就可以使用GDB提供的各种命令来调试该进程了,比如设置断点、单步执行、查看变量值等。
-
需要注意的是,当你附加到一个进程时,该进程会暂时被暂停执行,直到你在GDB中继续执行它。此外,如果你尝试附加到一个没有调试信息的进程(比如没有编译为带调试信息的版本),你可能无法查看所有的源代码和变量信息。
-
3.3 core文件
-
Core文件是Unix或Linux系统下程序崩溃时生成的内存映像文件,主要用于对程序进行调试。当程序出现内存越界、bug或者由于操作系统或硬件的保护机制导致程序异常终止时,或者段错误时,操作系统会中止进程并将当前内存状态导出到core文件中。这个文件记录了程序崩溃时的详细状态描述,程序员可以通过分析core文件来找出问题所在。
-
在Linux系统中,你可以使用GDB(GNU调试器)来调试core文件。GDB提供了一系列命令:
-
backtrace
(或简写为bt
)来查看运行栈 -
frame
(或简写为f
)来切换到特定的栈帧 -
info
来查看栈帧中的变量和参数信息 -
print
来打印变量的值等 -
从而定位和解决问题。
-
-
下图中,将出现的段错误导入至core文件中。随后执行gdb + 文件 + core文件
3.4 gdb中的堆栈帧
-
在GDB中,堆栈(stack)是用于存储函数调用时局部变量、返回地址等信息的一段连续的内存空间。当我们在GDB中查看堆栈信息时,通常会看到一系列的堆栈帧(stack frames),每一个堆栈帧对应一个函数调用。这些堆栈帧按照函数调用的顺序排列,最新的调用在最顶部,而较早的调用则位于下方。
-
每个堆栈帧都包含函数名、参数和返回地址等信息。
3.5 backtrace 与 frame 命令
3.6 print 命令 和 ptype 命令
a) p 输出
b) ptype
-
ptype
是一个在 GDB(GNU 调试器)中使用的命令,用于查看变量的类型。ptype
命令允许你在调试过程中查看某个变量的数据类型。 -
使用
ptype
命令的基本语法如下:-
ptype 变量名
-
-
例如,如果你有一个名为
my_variable
的变量,并想要查看它的类型,你可以在 GDB 中输入:-
ptype my_variable
-
-
GDB 会返回该变量的类型信息。
-
此外,
ptype
命令还支持一些可选参数,用于调整输出格式或提供额外的信息。例如:-
/r
:以原始数据的方式显示,不会替换一些typedef
定义。 -
/m
:查看类时,不显示类的方法,只显示类的成员变量。 -
/M
:与/m
相反,显示类的方法(默认选项)。 -
/t
:不打印类中的typedef
数据。 -
/o
:打印结构体字段的偏移量和大小。
-
-
-
这些选项可以通过在
ptype
命令后附加相应的参数来使用,以便根据需要调整输出。 -
需要注意的是,
ptype
命令仅在 GDB 的上下文中有效,并且你需要在已经启动并加载了相应程序的 GDB 会话中使用它。此外,ptype
命令只能查看当前作用域内可见的变量的类型。如果变量不在当前作用域内,你可能需要切换到包含该变量的作用域(例如,通过进入函数或切换到特定的堆栈帧)才能使用ptype
命令查看其类型。
3.7 info 命令 和 thread 命令
3.8 jump命令
基本命令
-
jump
命令的基本用法如下:-
jump <location>
-
-
这里的
<location>
可以是程序的行号、函数的地址,或者是源代码文件名和行号的组合。例如:-
跳转到第100行:
jump 100
---->>>>这种适合代码少的跳转,当代码繁多时采用修改寄存器的方法 -
跳转到函数
my_function
的开始处:jump my_function
-
跳转到文件
myfile.c
的第20行:jump myfile.c:20
-
-
使用
jump
命令时,需要注意以下几点:-
栈不改变:
jump
命令不会改变当前的程序栈中的内容。这意味着,如果从一个函数内部跳转到另一个函数,当返回到原函数时,可能会因为栈不匹配而导致错误。因此,最好在同一函数内部使用jump
命令。 -
后续执行:如果
jump
跳转到的位置后面没有断点,GDB会在执行完跳转处的代码后继续执行。如果需要暂停执行,可以在跳转目标处设置断点。 -
小心使用:
jump
命令可以强制改变程序的执行流程,这可能导致未定义的行为或程序崩溃。因此,在使用jump
命令时,需要谨慎并确保了解跳转的后果。 -
与
tbreak
配合使用:由于jump
命令执行后会立即继续执行,所以经常与tbreak
命令配合使用,在跳转的目标位置设置一个临时断点,以便调试者可以检查程序的状态。
-
修改寄存器(pc)
-
$pc
是一个特殊的变量,它代表程序计数器(Program Counter)的当前值。程序计数器是CPU中的一个寄存器,它存储了CPU将要执行的下一条指令的地址。换句话说,它指向了CPU当前正在执行的代码位置。 -
修改
$pc
的值你可以通过
set
命令来修改$pc
的值,从而改变程序执行的流程。但请注意,这样做非常危险,除非你确切知道你要跳转到的地址,并且该地址包含有效的指令。
-
情景:当需要停留在59行时,却多打了一步,在60行。
-
获得当前行的汇编地址代码
-
修改寄存器
-
-
目的:获取的59行的汇编地址,通过修改该行,让其在59行继续运行。
3.9 display命令
3.10 source命令
-
在Linux中,
source
命令用于在当前shell环境中执行指定的shell脚本文件,而不是创建一个新的子shell来执行。这意味着脚本中定义的任何变量或函数都会在执行完脚本后保留在当前shell环境中。 -
使用
source
命令的基本语法如下:-
source /path/to/script.sh
-
-
或者,你可以使用
.
(点)作为source
命令的简写:-
. /path/to/script.sh
-
-
source
命令通常用于以下场景:-
更新环境变量(加载配置文件)(主要用处):如果你修改了某个环境变量文件(如
~/.bashrc
或~/.bash_profile
),并且希望这些更改立即在当前shell会话中生效,而不是在打开新的shell会话时才生效,你可以使用source
命令来加载这些更改。-
source ~/.bashrc
-
-
执行初始化脚本:某些应用程序或工具可能需要运行初始化脚本以设置环境或执行其他一次性任务。使用
source
可以确保这些更改在当前shell中生效。 -
在当前shell中运行函数和别名:如果你在脚本中定义了一些函数或别名,并希望它们在当前shell中可用,那么使用
source
来执行脚本是合适的。
-
-
使用
source
命令而不是直接运行脚本(如./script.sh
)的主要区别在于环境变量的持久性。直接运行脚本会在子shell中执行,任何在脚本中定义的变量或更改的环境变量都不会影响父shell(即你当前所在的shell)。而使用source
命令,脚本中的变量和更改会直接影响当前shell。
3.11 save保存断点文件
-
提高调试的速度
其中,.txt中的文件的条件也可自行修改
查看文件内容
加载文件
3.12 watch命令
-
watch
命令在多种场景下都非常有用,比如:-
监视日志文件的变化,以便实时查看新添加的行或错误消息。
-
监视系统资源使用情况,如 CPU、内存或磁盘空间。
-
跟踪进程状态或性能数据。
-
-
请注意,
watch
命令会不断地执行指定的命令,这可能会对系统性能产生一定的影响,特别是在执行复杂或资源密集型的命令时。因此,在使用watch
命令时,请确保你了解其工作原理,并谨慎选择监视的命令和刷新间隔。
3.13 diff命令
-
diff
命令是 Linux 和类 Unix 系统中用于比较两个文件或目录的差异的实用工具。通过比较,diff
命令可以显示两个文件或目录之间的不同之处,以便用户可以了解它们之间的差异。 -
diff
命令的基本语法如下:-
diff [选项] 文件1 文件2
-
-
其中,
选项
是可选的,用于调整diff
命令的输出格式和行为,而文件1
和文件2
是你想要比较的两个文件。 -
以下是一些常用的
diff
命令选项:-
-c
或--context
:以上下文格式显示差异。这会显示文件之间的不同行,以及它们前后的几行上下文,有助于用户理解差异的具体位置。 -
-u
或--unified
:以统一的格式显示差异。这种格式与上下文格式类似,但显示方式稍有不同,通常用于补丁文件(patch files)。 -
-r
或--recursive
:递归比较目录及其子目录下的文件。当比较目录时,diff
会递归地遍历目录树,并比较其中的文件。 -
-i
或--ignore-case
:忽略大小写的差异。在比较时,不考虑字母的大小写。 -
-w
或--ignore-all-space
:忽略所有空格的差异。这包括空格、制表符等空白字符。 -
-B
或--ignore-blank-lines
:忽略空白行的差异。即不将只包含空白字符的行视为差异。
-
-
diff
命令的输出结果通常以<
和>
符号来表示差异。<
表示文件1中的内容,>
表示文件2中的内容。具体的差异行会以-
或+
符号开头,表示删除或添加的行。 -
除了上述常用选项外,
diff
命令还有其他一些选项,如-a
(将二进制文件视为文本文件进行比较)、-l
(将结果交由pr
程序来分页)等。 -
图中的.bak文件是备份文件。
3.14 多线程gdb调试步骤
-
ps -ef | grep main
:查看线程号 -
gdb attach
+ 线程号 -
info thread
:查看线程数量 -
thread 2
:表示查看第2 个线程 -
b + 文件名:行数
:打断点,随后直接run就行 -
info br
:查看断点信息 -
p + 参数
:表示想查看具体参数的细节 -
set pr pr
:设置打印好看 -
thread apply all bt
:所有线程都打印栈帧 -
f 3
:进入第三个线程的栈帧
(4)wget命令(redis安装)
4.1 wget命令
-
get是一个常用的命令行工具,用于从网络上下载文件。它支持多种协议,包括HTTP、HTTPS、FTP等,并提供了丰富的选项和参数以满足不同的下载需求。
-
wget命令的基本格式如下:
-
wget [选项] [参数]
-
其中,选项用于指定wget的行为,参数则用于指定要下载的文件或URL地址。
-
-
以下是一些常用的wget命令示例:
-
下载单个文件:例如在Downloads - Redis下载redis压缩包文件
-
wget https://download.redis.io/redis-stable.tar.gz
-
这个命令将从
https://download.redis.io/redis-stable.tar.gz
下载文件到当前目录。 -
然后开始使用tar zxvf redis-stable.tar.gz 进行解压
-
-
-
-
-
上面就是源码安装的过程
-
-
支持断点续传:
wget -c http://example.com/largefile.iso
使用-c
选项,如果下载过程中连接中断,wget可以从上次停止的地方继续下载。 -
后台下载文件:
wget -b http://example.com/background.mp3
使用-b
选项,wget会在后台执行下载操作,即使关闭终端也不会影响下载进程。 -
限速下载文件:
wget --limit-rate=300k http://example.com/slowdownload.zip
使用--limit-rate
选项,可以限制下载速度,这里限制为300k。 -
下载到指定目录:
wget -P /path/to/directory http://example.com/file.pdf
使用-P
选项,可以指定下载文件的保存目录。
-
4.2 redis(了解即可)
-
Redis(Remote Dictionary Server),即远程字典服务,是一个开源的使用ANSI C语言编写、支持网络、可基于内存亦可持久化的日志型、Key-Value数据库,并提供多种语言的API。以下是Redis的一些主要特性和应用:
-
速度快:由于Redis所有数据是存放在内存中的,并且其源代码采用C语言编写,距离底层操作系统更近,执行速度相对更快。此外,Redis使用单线程架构,避免了多线程可能产生的竞争开销。
-
基于K_V的数据结构:Redis提供了丰富的数据类型,包括字符串、哈希、列表、集合、有序集合等,并且每种数据类型都提供了丰富的操作命令。
-
功能相对丰富:Redis对外提供了键过期的功能,可以用来实现缓存。它还提供了发布订阅功能,可以用来实现简单的消息系统,解耦业务代码。此外,Redis还支持Lua脚本,提供了简单的事务功能(不能rollback),以及Pipeline功能,客户端能够将一批命令一次性传输到Server端,减少了网络开销。
-
扩展模块:Redis提供了一个模块叫做RedisJSON,它允许你在Redis中存储和查询JSON文档,支持多种查询语法和索引类型。这使得Redis能够模拟MongoDB等文档数据库的功能,同时由于其高性能和低延迟,你可以获得更快的响应速度和更好的用户体验。
-
搜索与可视化:Redis不仅可以存储数据,还可以对数据进行搜索和分析。你可以使用RediSearch来构建自己的搜索引擎,无论是针对网站内容、电商商品、社交媒体等领域,都可以实现高效和灵活的搜索功能。此外,你还可以使用RedisGraph来分析复杂的关系数据,并利用图形界面来展示数据的结构和特征。
-
性能优化:针对Redis可能遇到的性能问题,如内存溢出和IO瓶颈,可以采取一些优化措施。例如,选择合适的Redis数据结构来存储数据,将Redis的数据定期或实时保存到磁盘上,以及通过集群分片来拆分负载,实现性能的横向扩展。
-
(5)set args 和 show args
-
set args
: 这个命令可能是用来设置某些参数或变量的。例如,在一个脚本或程序中,你可能想要设置一些输入参数或配置选项,这时可以使用set args
来完成。 -
show args
: 这个命令可能是用来显示之前设置的参数或变量的。它可以让你查看当前已经设置的参数或变量的值。
git 命令
1、将服务器更新的内容上传至gitee仓库
-
上传gitee的流程如下
(1)git add
-
git add [文件名|目录名] 将对应的文件|目录放到暂存区
(2) git commit
-
git commit -m"[备注]" 将暂存区的内容放到对象区,并添加这次上传的备注信息
-
git commit --amend -m"[新的备注]" 修改暂存区这次上传的备注信息
(3) git push
-
git push [远程仓库名] [本地分支名] [远程分支名]
-
将本地的指定分支推送到指定仓库的指定分支上
-
-
例如 git push origin master:refs/for/master ,即是将本地的master分支推送到远程主机origin上的对应master分支, origin 是远程主机名
-
注:一个简单的个人理解分支和git add、git commit和git push的关系:git add和commit是将所有更新的内容放进对象区,然后git push 是在对象区内按分支挑选内容上传至仓库
3.1 git push origin master
-
如果远程分支被省略,如上则表示将本地分支推送到与之存在追踪关系的远程分支(通常两者同名),如果该远程分支不存在,则会被新建
3.2 git push origin :refs/for/master
-
如果省略本地分支名,则表示删除指定的远程分支,因为这等同于推送一个空的本地分支到远程分支,等同于 git push origin --delete master
3.3 git push origin
-
如果当前分支与远程分支存在追踪关系,则本地分支和远程分支都可以省略,将当前分支推送到origin主机的对应分支
3.4 git push
-
如果当前分支只有一个远程分支,那么主机名都可以省略,可以使用git branch -r ,查看远程的分支名
3.5 git push 的其他命令
-
这几个常见的用法已足以满足我们日常开发的使用了,还有几个扩展的用法,如下:
-
(1) git push -u origin master 如果当前分支与多个主机存在追踪关系,则可以使用 -u 参数指定一个默认主机,这样后面就可以不加任何参数使用git push
-
- • (2) git push --all origin 当遇到这种情况就是不管是否存在对应的远程分支,将本地的所有分支都推送到远程主机,这时需要 -all 选项
- • (3) git push --force origin git push的时候需要本地先git pull更新到跟服务器版本一致,如果本地版本库比远程服务器上的低,那么一般会提示你git pull更新,如果一定要提交,那么可以使用这个命令。
- • (4) git push origin --tags //git push 的时候不会推送分支,如果一定要推送标签的话那么可以使用这个命令。
(4)git status
-
git status 工作区、暂存区内文件|目录的状态
-
$\textcolor{red}{红色}$:表示文件|目录在工作区
-
$\textcolor{green}{绿色}$:表示文件|目录在暂存区
(5) commit版本与hard指针(待完善)
(6)git reset
6.1 git reset --hard
-
git reset --hard [commit版本] 清空工作区和缓存区,回退仓库版本
-
首先git log获取commit版本号
-
仓库版本回退
-
清空工作区和缓存区
-
注:使用后一定要用git log查询版本状态
6.2 git reset --mixed
-
git reset --mixed
-
将暂存区的内容回退到工作区,即可用于恢复git add操作
-
注:--mixed为默认参数,即可以直接输入git reset
-
本地仓库版本回退
6.3 git reset --soft
-
git reset --soft [commit版本] 将对象区的内容回退到暂存区,即可用于恢复git commit操作
提交的内容回到暂存区
6.4 git reset [commit版本]
-
git reset [commit版本]
-
将对象区的内容回退到工作区,即可用于恢复git commit操作
-
同上一节,区别在于内容回退到工作区而不是暂存区
(7)git log
-
git log
是 Git 中的一个命令,用于显示仓库的提交历史(查看日志)。它提供了详细的提交信息,包括每次提交的哈希值、提交者、提交日期、提交信息以及涉及的改动文件等。 -
以下是
git log
的一些常见用法和选项:
git log
-
这将会显示当前分支上的所有提交,从最近的提交开始,按时间顺序逆序排列。
-
后面-3表示显示3条历史
-
如果git log查不出来所需要的版本号,可以使用git reflog
2、git分支管理
(1) 什么是分支
-
多个程序员分发同一程序时,由于不能直接在一个程序上进行功能的开发,所以就有了功能分支的概念。
-
功能分支指的是专门用来开发新功能的分支,它是临时从master主分支上分叉出来的,当新功能开发且测试完毕后,最终需要合并到master主分支上,如图所示:
(2)git branch
-
git branch 查询本地所有分支
-
git branch -r 查询远程所有分支
-
git branch -a 查询本地和远程所有分支,并显示详细内容
-
git branch [分支名称] 创建分支并命名
-
git branch -d [分支名称] 删除分支
-
git branch -m [原名称] [新名称] 重命名分支
(3)git checkout
-
git checkout [分支名称] 切换分支
-
git checkout -b [分支名称] 创建并切换到分支
(4)git merge
-
git merge [分支名称] 将当前分支与指定名称分支合并
-
例如 git checkout master
-
-
git merge develop 将master分支和develop分支合并
3、git init
-
git init
是一个Git命令,用于初始化一个新的Git仓库。当你运行这个命令时,Git会在当前目录下创建一个名为.git
的子目录,这个子目录包含了仓库中所有的元数据对象以及必要的配置文件。 -
以下是
git init
的基本用法: -
在当前目录下初始化Git仓库:
-
如果你在某个项目目录下,并希望开始使用Git来跟踪该项目的版本,你可以简单地运行:
-
git init
-
之后,你会看到
.git
目录在当前目录下被创建。这个目录包含了Git的所有核心组件,如对象数据库、引用等。 -
在指定目录下初始化Git仓库:
-
你也可以使用
git init
命令来初始化一个指定目录为Git仓库。例如,如果你想在名为myproject
的目录下初始化一个新的Git仓库,你可以这样做:
-
mkdir myproject cd myproject git init
-
带有参数的
git init
:-
虽然
git init
通常不需要任何参数,但有一个--bare
选项,它用于创建一个不包含工作树的裸仓库。裸仓库主要用于存储共享项目的中央版本历史记录,例如,在Git服务器上。
-
git init --bare
-
使用
--bare
选项创建的仓库不包含工作目录(即没有.git
目录之外的任何文件),因此你不能直接在这个仓库中进行工作。 -
总的来说,
git init
是你开始使用Git跟踪项目版本的第一步。
4、git config --global +配置信息
-
git config --global
是 Git 的一个命令,用于设置全局的 Git 配置选项。这些选项会应用于当前用户的所有 Git 仓库。 -
当你运行
git config --global
命令时,你实际上是在修改位于你用户主目录下的.gitconfig
文件。这个文件存储了全局的 Git 配置信息。 -
这里有几个使用
git config --global
的例子:
-
设置用户名:
git config --global user.name "Your Name"
-
这条命令会设置你在提交时所使用的用户名。Git 会使用这个用户名来标识你提交的代码。
-
设置邮箱地址:
git config --global user.email "your.email@example.com"
-
这条命令会设置你在提交时所使用的邮箱地址。与用户名类似,Git 会使用这个邮箱地址来标识你提交的代码。
5、git diff 和 git pull
5.1 git diff
-
git diff
是一个用于在 Git 版本控制系统中比较文件差异的命令。它可以用来比较暂存区与工作区之间的差异、比较当前工作区与最后一次提交之间的差异,或者比较两次提交之间的差异。通过git diff
,用户可以清晰地看到代码或文件内容的更改。 -
下面是
git diff
的一些常用用法和选项: -
比较工作区与暂存区的差异
-
git diff
-
这个命令会显示工作区中所有尚未暂存的更改。它会列出工作区与暂存区(即
git add
命令之前的状态)之间的差异。
-
-
比较暂存区与最近一次提交的差异
-
git diff --cached
-
或者
-
git diff --staged
-
这个命令会显示暂存区(即
git add
命令之后的状态)与最近一次提交之间的差异。这有助于用户检查即将提交的更改。
-
-
比较两个提交之间的差异
-
git diff <commit-hash1> <commit-hash2>
-
这个命令会显示两个指定提交之间的差异。
<commit-hash1>
和<commit-hash2>
是提交的哈希值或引用(如分支名或标签名)。
-
-
比较当前工作区与指定提交的差异
-
git diff <commit-hash>
-
这个命令会显示当前工作区与指定提交之间的差异。
-
-
使用
--word-diff
或--color-words
选项进行更详细的比较-
git diff --word-diff
-
或者
-
git diff --color-words
-
-
这些选项会以更详细的方式显示差异,包括高亮显示发生变化的单词。
-
-
比较两个文件之间的差异
-
git diff -- <file1> <file2>
-
这个命令会直接比较两个文件的内容,而不需要它们处于 Git 仓库中。这可以用来比较任何两个文件,而不仅仅是 Git 仓库中的文件。
-
-
其他选项
-
git diff
还有许多其他选项,可以用来定制输出格式、忽略空白字符、限制比较范围等。例如,--stat
选项可以显示一个简要的统计信息,而--name-only
选项则只列出有差异的文件名。
-
5.2 git pull
-
git pull
命令用于从远程仓库获取最新的更新,并将其合并到当前分支。 -
具体来说,
git pull
命令做了两件事:-
Fetch:从远程仓库下载最新的更改。这不会改变你当前的工作或任何你本地的提交,但它会更新你的本地仓库,以便你知道远程仓库的最新状态。
-
Integrate:通常是通过合并(merge)或变基(rebase)操作,将远程仓库的更改集成到你的本地分支。
-
-
git pull
命令也有一些有用的选项:-
--rebase
:使用变基而不是合并来集成更改。这可以保持一个线性的提交历史,但也可能导致更复杂的冲突解决。 -
-v
或--verbose
:显示更详细的输出信息。 -
--ff-only
:仅当可以通过快速前进(fast-forward)方式合并时,才执行拉取操作。这可以防止产生一个新的合并提交。 -
--no-commit
:在合并或变基后,暂停提交,允许用户手动检查和修改更改。
-
-
示例
-
从
origin
的master
分支拉取并集成更改:-
git pull origin master
-
-
从上游分支拉取并集成更改(假设上游分支已经设置):
-
git pull
-
-
使用变基从上游分支拉取并集成更改:
-
git pull --rebase
-
-
请注意,在使用
git pull
之前,最好先运行git fetch
来查看远程分支的最新状态,而不立即集成这些更改。这可以帮助你更好地理解将要合并或变基哪些更改,以及是否有可能出现冲突。如果你预计会出现复杂的合并或变基情况,先执行git fetch
可能会更安全。
6、git branch
-
git branch
是 Git 中的一个命令,用于列出、创建和删除仓库中的分支。分支是 Git 中的一个核心概念,它允许你并行地开发多个功能或修复多个问题,而不会相互干扰。 -
以下是
git branch
的一些常见用法:
(1)列出所有分支
git branch
这个命令会列出当前仓库中的所有分支。当前活动的分支前面会有一个星号(*)标记。
(2)列出所有分支(包括远程分支)
git branch -a
-
使用
-a
或--all
选项会列出所有本地分支和远程跟踪分支。远程跟踪分支通常以remotes/origin/
开头。 -
git branch -av
用于列出所有的分支,并显示每个分支的最后一次提交的详细信息。具体来说: -
-
git branch
是用于列出所有分支的命令。 -
-a
选项表示列出所有分支,包括远程跟踪分支。 -
-v
选项表示显示每个分支的最后一次提交的详细信息。
-
(3)创建新分支
git branch <branch-name>
这个命令会创建一个新的分支,但是并不会切换到这个新分支。<branch-name>
是你想要创建的分支的名称。
(4)切换到新分支
git checkout <branch-name> git checkout -b <branch-name>//创建一个新的分支并立即切换到这个分支
在较新版本的 Git 中,推荐使用 git switch
命令来切换分支。这两个命令都会创建一个新的分支并立即切换到这个分支。
(5)删除分支
git branch -d <branch-name>
这个命令会删除一个已存在的本地分支。注意,你不能删除当前活动的分支。
(6)强制删除分支
git branch -D <branch-name>
使用 -D
选项可以强制删除一个分支,即使它包含未合并的更改。
(7)跟踪远程分支
如果你想创建一个本地分支来跟踪一个远程分支,你可以这样做:
git branch --track <local-branch-name> <remote-branch-name>
(8)重命名分支
Git 本身没有直接重命名分支的命令,但你可以通过两步来实现:首先创建一个新的分支,然后将原始分支删除。
git branch -m <old-branch-name> <new-branch-name>
这个命令会重命名当前活动的分支。
7、企业gitee分支的步骤
-
企业级使用Gitee分支的操作步骤主要包括以下几个环节:
-
创建和初始化本地仓库:首先,在本地计算机上创建并初始化一个新的Git仓库。这通常通过
git init
命令完成。 -
提交代码到本地仓库:将你的代码文件添加到本地仓库,并使用
git add
命令将其暂存。然后,使用git commit
命令为这次提交添加注释。 -
建立与远程仓库的连接:如果还没有与远程仓库建立连接,你需要使用
git remote add origin “xxx.git”
命令来添加远程仓库的URL。这里的“xxx.git”应替换为你的Gitee仓库的实际URL。 -
创建并切换分支:使用
git branch 分支名
命令创建新的分支,然后使用git checkout 分支名
命令切换到新创建的分支。你也可以使用git switch -c 分支名
命令一次性创建并切换到新分支。 -
在Gitee仓库中创建分支:如果你希望在Gitee的在线仓库中也创建分支,可以登录Gitee,进入你的仓库页面,点击左侧菜单栏中的“提交”,在右侧页面上方选择“创建新分支”,输入新分支的名字,并选择基于哪个分支创建新分支。
-
提交分支到远程仓库:当你完成本地分支的修改后,可以使用
git push origin 分支名
命令将本地分支推送到Gitee的远程仓库。
-
-
主要分支步骤
-
当新分支的代码修改完成后,你可能需要将这个分支合并到主分支(通常是master或main分支)。合并分支的常用方式是使用
git merge
命令。 -
首先,切换到主分支:
git checkout master
(或git checkout main
,取决于你的主分支名称)。 -
然后,执行合并操作:
git merge <新分支名>
。Git会尝试将新分支的修改合并到主分支。 -
如果在合并过程中发生了冲突,Git会提示你解决冲突。你需要手动编辑文件以解决这些冲突,然后再次提交修改。
-
最后,将合并后的主分支推送到远程仓库:
git push origin master
(或git push origin main
)。
-
-
gitee软件步骤
-
在分支页面点击贡献代码,并创建Pull Request
-
在创建页面,填写备注等信息 下拉,点击创建,并在右边选项选择合适的功能,把合并删除提交分支也选上,避免后面出现许多分支。
创建好后,管理者可以查看并开始审查
然后开始合并,合并的目的就是将分支的修改后的代码传至主支上(master)
回到工作区,一定先要git pull拉一下代码(更新一下)
8、git merge + 文件名
-
将其文件合并至master分支,必须先切换到master分支。
9、git stash (压栈操作)
git stash
是 Git 中的一个命令,用于临时保存当前工作目录和暂存区的修改,以便你可以切换到其他分支进行工作,然后再回来继续之前的工作。当你想要保存当前的工作进度,但又不想提交一个不完整的更改时,git stash
就非常有用。
以下是 git stash
的一些常见用法:
(1)保存当前工作进度**
git stash
这个命令会将你当前工作目录和暂存区的所有修改保存起来,并重置工作目录为最近一次提交的状态。所有未提交的修改都会被存储起来,你可以随时应用它们。
(2)查看存储的工作进度列表
git stash list
这个命令会列出所有被 git stash
保存的工作进度。每个工作进度都有一个唯一的名称(通常是一个哈希值),你可以使用这个名字来引用特定的工作进度。
(3)应用存储的工作进度
git stash pop
这个命令会取出最近一次保存的工作进度,并应用到当前的工作目录和暂存区。如果应用成功,该工作进度会从列表中删除。如果应用时出现冲突,你需要手动解决冲突后再继续。
(4)应用特定的工作进度
git stash apply stash@{n}
这里的 stash@{n}
是你想要应用的工作进度的名称。n
是一个整数,表示工作进度在列表中的位置,最近的保存是 stash@{0}
,然后是 stash@{1}
,以此类推。
(5)丢弃存储的工作进度
git stash drop stash@{n}
这个命令会丢弃指定的工作进度,从列表中删除它。如果你不指定名称,它会默认丢弃最近一次保存的工作进度。
(6)结合 pop 和 drop
如果你想要应用一个工作进度,并立即丢弃它(无论是否出现冲突),你可以使用:
git stash pop stash@{n}
或者简单地:
git stash pop --index
这会同时恢复工作目录和暂存区的状态。
通过 git stash
,你可以轻松地管理你的工作进度,避免因为需要切换到其他分支而丢失当前的修改。
(7)流程
-
当执行当前操作时,需要修改其他文件的bug,因此需要git stash进行保存
-
然后切换至需要修改的分支中bug文件进行修改
修改后的文件进行上传,然后合并至master分支
解决后需要再次回到原文件分支对其进行出栈操作git stash pop
10、分支的冲突与拒绝
(1)分支冲突
-
分支冲突通常发生在尝试将两个或更多并行开发的分支合并到一个共同分支时。这些分支可能同时对同一部分代码进行了修改,而Git无法自动确定应该保留哪个版本的修改。这可能导致合并冲突。
-
冲突可能由以下原因引起:
-
修改同一行代码:即使不同的开发者在不同的文件上进行了修改,但如果同时修改了同一行的代码,合并时也会发生冲突。
-
重命名文件或移动文件:如果一个分支对文件进行了重命名或者移动,而另一个分支对相应的文件进行了修改,合并时就会产生冲突。
-
合并历史问题:有时候,如果两个分支有完全不同的提交历史,尤其是当一个分支是另一个分支的重新创建或者重写时,合并时会遇到困难。
-
使用了不同的换行符:在不同的操作系统中,换行符可能会不同。如果不同的分支使用了不同的换行符,合并时可能会产生冲突。
-
-
解决冲突的方法通常包括手动编辑冲突文件,解决冲突后再提交合并请求。在某些情况下,也可以使用工具来帮助解决冲突。
-
出现冲突后不能删除冲突代码
(2)冲突解决方法
-
编辑冲突文件:当Git提示合并冲突时,你需要手动打开冲突文件并查看其中的特殊标记。这些标记通常包括
<<<<<<<
、=======
和>>>>>>>
,它们分别表示当前分支的内容、两个分支的共同祖先的内容以及要合并的分支的内容。你需要根据实际需求,决定保留哪些内容,删除哪些内容。 -
使用工具解决冲突:有些IDE或代码编辑器提供了专门的工具来帮助解决Git冲突。这些工具可以直观地显示冲突的部分,并允许你通过点击或拖拽来选择保留哪些更改。
-
添加文件到暂存区并提交:解决完冲突后,你需要将修改后的文件添加到Git的暂存区,并提交合并结果。这可以通过
git add <文件名>
和git commit -m "合并描述"
命令来完成。注意,提交时应该提供清晰的描述,说明这次合并的内容和目的。 -
解决持续冲突:如果在合并过程中遇到持续冲突,即每次尝试合并都会触发相同的冲突,你可能需要仔细检查代码,并考虑重新设计代码结构或分工,以避免未来的冲突。
-
将黄色部分删除。然后将共同冲突的头文件移动到上面去。
-
(3)分支拒绝
-
分支拒绝通常发生在尝试将代码提交到远程分支或合并其他分支到当前分支时,但Git由于某些原因拒绝了这一操作。
-
以下是一些可能导致分支被拒绝的原因:
-
权限不足:你可能没有足够的权限将代码提交到远程分支。
-
分支保护规则:仓库可能设置了保护规则,限制了谁可以将代码提交到特定分支。
-
未完结任务:如果当前分支有未提交的更改,而你又试图合并其他分支,Git会拒绝合并请求。
-
已被其他分支领先:当你的当前分支被其他分支超过时,Git会拒绝合并请求。
-
合并策略冲突:某些情况下,你可能会设置了合并策略,但Git在合并时无法使用该策略,从而拒绝合并请求。
-
-
解决分支拒绝的问题通常需要根据具体原因采取相应的措施,如获取足够的权限、满足分支保护规则、提交或撤销当前分支的更改、更新当前分支以与其他分支保持同步或调整合并策略等。
-
总结:被拒绝可能分支比较落后,因此需要每次在master分支使用git pull拉一下代码,更新。不只针对拒绝,其他也适用,必须要git pull拉一下代码更新
(4)拒绝的解决方法
-
权限不足
-
确认你是否有足够的权限进行提交或合并操作。
-
如果没有权限,联系仓库拥有者或管理员,请求相应的权限。
-
作为替代方案,你可以在本地创建新的分支或提交更改,然后请求他人合并你的更改。
-
-
分支保护规则
-
查看仓库的分支保护设置,了解哪些分支受到保护,以及保护规则是什么。
-
如果你的提交或合并请求违反了保护规则(例如,直接推送到受保护的分支),你需要将更改合并到受保护分支所依赖的分支上,然后再进行提交或合并请求。
-
-
存在冲突
-
使用
git status
命令查看冲突文件。 -
手动编辑冲突文件,解决冲突部分。
-
使用
git add
命令将解决冲突后的文件标记为已解决。 -
继续执行合并或提交操作。
-
-
未完结的本地更改
-
如果你有未提交的本地更改,先提交或暂存这些更改,再进行合并或推送操作。
-
使用
git stash
命令可以暂时保存当前更改,以便稍后恢复。
-
-
网络问题或远程仓库问题
-
检查你的网络连接是否正常,确保能够访问远程仓库。
-
如果远程仓库已满或存在其他问题,联系仓库管理员解决。
-
-
配置错误或仓库状态异常
-
检查你的Git配置是否正确,包括远程仓库的URL等。
-
如果仓库状态异常,考虑克隆一个新的仓库副本,并在新的副本上工作。
-
-
使用错误的命令或参数
-
确保你使用的Git命令和参数是正确的。
-
查阅Git文档或相关教程,了解正确的命令用法。
-
数组/指针/函数
1、数组
(1)特点
-
连续的存储空间
-
存储相同的数据类型。
-
整个代码不允许使用魔数(即str[5],5是魔数),应该使用宏,str[BUFFER_SIZE]。
(2)清理脏数据
-
将数组里面数据全置为空
-
memset
memset(array, 0, sizeof(array));
-
bzero
bzero(array, sizeof(array));
注:虽然 bzero 是一个常用的函数,但在 POSIX 标准中,它已经被标记为过时,并推荐使用 memset 函数来替代。memset 函数提供了与 bzero 相同的功能,但更为通用,并且可以在非零值上进行操作。
(3)额外知识点
-
\:一般来说,\代表转义。
-
数组作为函数的参数,会自动若化成指针,导致调用函数时传出的数组大小不一样,因此函数的参数必须加上数组大小,保证传进去的数组大小跟定义的一样。
-
调用函数时的数组大小
-
函数里面的数组大小:弱化成指针了
2、函数
(1)函数三要素
-
函数名:定义的函数名的时候一定要准确,做到见名知义。
-
函数参数:形参
-
函数返回值:int, char等
(2)函数参数
-
函数的参数为形参
-
调用函数的参数为实参
-
解引用:指获取指针或引用所指向的内存地址中的值。指针是一个变量,它存储的是另一个变量的内存地址。要获取该地址中存储的实际值,你需要对指针进行解引用。解引用操作通常使用星号(*)符号进行。
-
在main函数中,将newsize的地址传给dedupArray函数,然后后通过解引用获取该函数中的值。
(3)传入参数和传出参数
1、传入参数
-
对于整数而言,没有指针的就是传入参数,就是所谓的值传递。
-
对于字符串而言,没有const限定符的是传入参数。
2、传出参数
-
对于整数而言,有指针的就是传入参数,就是所谓的地址传递。
-
对于字符串而言,有const限定符的一定是传出参数。
3、const限定符
-
const修饰的是常量,常量不可被修改。
4、用法
-
如果在关于字符串的函数参数中,不需要对函数中的字符串进行修改,需要加上限定符const。
-
以后传入指针的时候,必须都要判空。
数组即指针
(4)函数声明
-
.h文件被称为头文件
-
下面的宏的作用是避免头文件重复包含
#ifndef _FUNC_H_ #define _FUNC_H_ #endif //_FUNC_H_
(5)函数的首地址
-
函数的首地址是指函数在内存中的起始位置,即函数第一条指令的地址。在C和C++等编程语言中,函数名本质上就是一个指向该函数首地址的常量指针。当程序被编译后,每个函数都会被分配一段连续的内存空间,而这段内存空间的起始地址就是函数的首地址。
-
在C语言中,你可以通过取地址操作符
&
来获取一个函数的首地址,并将其赋给一个函数指针变量。例如:
int myFunction() { // 函数体 } int (*funcPtr)() = &myFunction; // 将myFunction的首地址赋给funcPtr
-
在这个例子中,
funcPtr
是一个函数指针变量,它存储了myFunction
的首地址。你可以通过这个函数指针来调用myFunction
函数:
int result = (*funcPtr)(); // 使用函数指针调用myFunction
-
需要注意的是,函数指针的类型必须与它所指向的函数类型相匹配。在上面的例子中,
funcPtr
的类型是int (*)()
,这表示它是一个指向返回int
类型且不接受任何参数的函数的指针。 -
在C++中,函数指针的概念与C语言中类似,但语法上可能有些许差异。同样地,函数名可以作为指向函数首地址的指针使用,并且可以通过函数指针来调用函数。
3、指针
(1) 初始化
-
指针若不初始化,指针就是野指针。
char *ptr = NULL;
void * :是万能指针,可以强转成任意指针类型。
(2)数组与指针
-
数组是存放在栈空间的;
-
指针是指向NULL的,是一段受保护的地址,不能被使用,因此需要在堆空间分配空间,不然会出现段错误 。
-
分配完之后一定要判断是否为空!!!!!!!!
区别
-
在空间分配上,数组是静态分配空间,空间是物理连续的,且空间利用率低。指针变量是需要动态分配内存空间,内存空间在堆上分配与释放,程序员管理。
-
数组名是指针常量,一维数组名是首个元素的地址,二维数组名是首个一维数组的地址。指针是-一个变量,可以指向任意一块内存,使用时要避免野指针的产生造成内存泄漏。
-
数组的访问效率高,数组的访问方式等于指针的访问方式取*
-
数组使用容易造成数组越界,指针的使用容易产生野指针造成内存泄漏。
-
函数传参时,使用万能指针可以提高代码的通配性。数组作为形参传递时会默认退化成相应的指针。
-
数组只提供了一种简单的访问机制,指针可以对地址直接操作来访问硬件的。
(3)检查内存泄露
1-内存泄漏的三个原因
-
野指针
-
malloc申请的地址没有free释放
-
踩内存
2-踩内存
-
含义:需要赋值的字符串超过固定分配内存的大小,造成内存泄漏。
-
解决方法:采用安全函数strncpy( )函数。
3-内存泄漏检查
如果指针分配内存后没有进行释放,则会导致内存泄漏,则可以使用一下代码进行检测。
valgrind --tool=memcheck --leak-check=yes --show-reachable=yes + 所跑出的文件(./a.out)
4-perror与exit
-
perror( const char *s );
-
作用:将字符串参数打印至控制台,且打印程序错误信息
-
注:没有成功让程序打印错误信息,但我在网上找到了C++找不到文件时的错误信息。另外,这里没有使用 exit() 函数
#include<stdio.h> #include <errno.h> #include <string.h> int main(void) { FILE *fp; fp = fopen("/home/book/test_file","r+"); if (NULL == fp) { perror("fopen error"); } return 0; }
-
输出结果:fopen error: No such file or directory
-
exit( int status )
-
作用:正常终止程序,通常在 perror () 函数后使用
(4)解决不完整类型问题
4.1 前向声明
-
前向声明(Forward Declaration)的逻辑基于编译器如何处理标识符的解析和类型信息的获取。在编程中,当我们使用某个类型(比如类、结构体、枚举等)时,编译器需要知道这个类型的完整定义,以便能够正确地进行类型检查和代码生成。然而,在某些情况下,我们可能希望在完全定义类型之前就引用它,这时就需要使用前向声明。
-
前向声明的逻辑如下:
-
提前告知编译器:通过前向声明,我们告诉编译器即将使用到某个类型,但此时并不提供该类型的完整定义。这允许编译器在后续的代码中识别该类型的引用,而不会立即报错。
-
占位符作用:前向声明实际上是一个占位符,它告诉编译器在后续代码中会找到该类型的完整定义。编译器会在后续的编译过程中查找这个完整定义,以确保类型使用的正确性。
-
限制使用:由于前向声明没有提供类型的完整信息,因此我们不能使用它来创建该类型的实例或访问其非静态成员(除非这些成员是之前已经声明过的指针或引用类型)。我们只能声明指向该类型的指针或引用,或者将该类型用作函数参数的类型。
-
包含头文件:在使用前向声明的类型之前,我们最终需要包含定义该类型的头文件,以确保编译器在编译过程中能够找到该类型的完整定义。这通常发生在实现文件的开始部分,或者在使用到该类型的具体细节之前。
-
避免循环依赖:前向声明常用于解决头文件之间的循环依赖问题。通过前向声明,我们可以打破头文件之间的直接包含关系,从而避免循环依赖导致的编译错误。
-
-
情景:在test.c里面定义一个动态数组结构体,在test.h里面声明一下(typedef struct ......),在main.c文件中调用test.h文件,然后使用结构体时出现不完整类型的错误。
-
解决方法:将其定义成指针的形式,然后在test.c文件中的调用动态数组的函数中,例如初始化函数中,将其定义成二级指针的形式。
4.2 将其声明成指针的本质
-
通过指针,我们不是在直接操作一个对象或类型本身,而是在操作一个指向该对象或类型的内存地址。这种间接引用的方式允许我们在不完全了解或定义某个类型的情况下,就能声明和使用指向该类型的指针。
-
具体来说,当我们声明一个指向某个类型的指针时,我们实际上是在告诉编译器:“我想要一个能够存储某种类型对象内存地址的变量。”编译器并不需要知道这个类型的完整定义,它只需要知道这个类型存在,以便为指针变量分配足够的空间来存储地址。
-
这种间接性有几个重要的好处:
-
解决不完全类型问题:如前所述,当我们在某个类型的完整定义之前就需要引用它时,可以通过声明指向该类型的指针来避免编译错误。这是因为指针的大小是固定的(通常是机器字长,如32位或64位),与它所指向的类型无关。
-
动态内存管理:指针经常与动态内存分配(如使用
new
或malloc
)一起使用,允许我们在运行时创建对象,并将指针指向这些对象的内存地址。这种灵活性是静态数组或直接在栈上分配的对象所不具备的。 -
多态性和接口:在面向对象的编程中,指针(或引用)是实现多态性的关键。通过将基类指针指向派生类对象,我们可以实现运行时多态性,即同一接口可以有多种实现。
-
传递大型数据结构:通过传递指针而不是整个数据结构,我们可以避免复制大型对象,从而提高性能。函数接收的是指向数据的指针,而不是数据的副本。
-
修改外部数据:通过指针,函数可以修改调用者传递的数据,因为指针提供了对数据实际存储位置的直接访问。
-
(5)函数指针
-
函数指针是一个变量,它存储了一个函数的地址。你可以通过这个指针来调用这个函数。函数指针的声明通常包括函数的返回类型和参数类型。
-
例如,假设你有一个如下定义的函数:
int add(int a, int b) { return a + b; }
-
你可以声明一个指向这个函数的指针:
c复制代码 int (*func_ptr)(int, int);
-
然后,你可以将这个函数的地址赋给这个指针,并通过这个指针来调用函数:
func_ptr = &add; // 也可以写作 func_ptr = add; 在C和C++中,函数名本身就是地址 int result = func_ptr(3, 4); // 这将调用add函数,并将结果存储在result中
(6)指针函数
-
指针函数实际上是返回一个指针的函数。它的返回类型是一个指针,而不是函数本身。指针函数可以有任意数量的参数,其声明方式与普通函数类似,只是返回类型是一个指针。
-
例如,以下是一个返回整数指针的函数:
int* get_array() { static int arr[] = {1, 2, 3, 4, 5}; return arr; }
-
在这个例子中,
get_array
是一个返回整数指针的函数。它返回了一个指向静态整数数组的指针。 -
总结:
-
函数指针:是指向函数的指针,通过它可以调用函数。回调函数就是定义成函数指针的
-
指针函数:是返回指针的函数,其返回类型是一个指针。
-
-
理解这两个概念的关键在于区分它们的作用:函数指针用于间接调用函数,而指针函数则用于返回某个类型的指针。
4、二维数组
(1)定义
-
array[m][n]
=*(*(array + m) + n)
-
二维数组可以理解为是存储指针的一维数组,里面的每一个指针都指向一个一维数组的首地址
-
存储多个一维数组的数据结构。
-
array 是一个二维数组,它有3行和4列,总共可以存储12个整数。你可以通过行索引和列索引来访问数组中的元素,例如 array[0] [0] 访问的是第一行第一列的元素,array[2] [3] 访问的是第三行第四列的元素。
-
二维数组在内存中是连续存储的,通常按行优先或列优先的方式排列。通常使用行优先的方式,即先存储第一行的所有元素,然后是第二行,依此类推。
int array[ROW][COLUMN]; //赋值 int value = 0; for(int idx1 = 0; idx1 < ROW; idx1++) { for(int idx2 = 0; idx2 < COLUMN; idx2++) { array[idx1][idx2] = ++value; } } //两者均为 6,即array[m][n] = *(*(array + m) + n) printf("array[1][2]\t\t%d\n", array[1][2]); printf("*(*(array + 1) + 2)\t%d\n", *(*(array + 1) + 2)); //array[m][n] = *(*(array + m) + n) printf("&array[1][2]\t\t%p\n", &array[1][2]); printf("*(array + 1) + 2\t%p\n", *(array + 1) + 2); //二维数组是存储指针的一维数组,里面的每一个指针都指向一个一维数组 printf("&array[0][2]\t\t%p\n", &array[0][2]); //这里在array的基础上加了两个int的字节,即2*4=8 printf("array + 2\t\t%p\n", array + 2); //这里在array的基础上加了两个int array[3]的字节,即2*3*4=24 printf("*array + 2\t\t%p\n", *array + 2); //这里在array的基础上加了两个int的字节,即2*4=8
(2)位置关系
5、一级指针与二级指针
(1) 空链表
-
当创建一个单链表时使用的是一级指针
定义一个指针指向结点head,即创建了一个链表的头指针
BalanceBinarySearchNode *head head->NULL;
-
当在空链表时的链表尾插操作中,需要更改了头指针head的指向,因此在函数中要使用到二级指针,这里前提是头指针。
-
(2)非空链表
-
一段非空链表:head->node->node1->node2->NULL
-
若想插入尾插,直接将node2->newnode,因此需要更改的是node2结构体的指针域的存储内容,因此这时我们操作只需要node2结构体的地址,即一级指针。
-
链表中传入二级指针的原因是我们会遇到需要更改头指针head的指向的情况。如果我们仅是在不改变头指针head的指向的情况下对链表进行操作(如非空链表的尾删,尾插,对非首结点(FirstNode)的结点的插入/删除操作等),则不需要用到二级指针.
(3)二级指针的例子
-
下面是一个使用二级指针的例子,它同时展示了如何修改指针的值和访问指针所指向的值:
#include <stdio.h> void modifyPointerAndPrint(int **pptr, int value) { // 打印当前指针所指向的值 printf("Original Value: %d\n", **pptr); // 修改指针使其指向一个新的地址(这里只是作为一个例子,实际上你可能不会这样做) int new_value = 20; // 新的值 *pptr = &new_value; // 修改指针使其指向new_value的地址 // 打印修改后指针所指向的值 printf("Modified Value: %d\n", **pptr); // 注意:new_value是在函数栈上分配的,当函数返回时,这个内存可能不再有效 // 如果你需要让指针在函数外部仍然有效,你需要确保分配的内存是持久的(例如使用malloc) } int main() { int x = 10; int *ptr = &x; // 一级指针,指向x printf("Before modification, ptr points to x: %d\n", *ptr); // 将ptr的地址(即二级指针)和x的值传递给函数 modifyPointerAndPrint(&ptr, x); // 注意:此时ptr已经指向了函数内部的局部变量new_value // 这个值在函数返回后可能已经无效了 printf("After modification, ptr points to new_value (might be invalid): %d\n", *ptr); // 如果你需要让ptr在函数外部仍然有效,你应该在函数内部使用malloc来分配内存 // 并在适当的时候使用free来释放内存 return 0; }
-
注意:在上面的例子中,
new_value
是在modifyPointerAndPrint
函数的栈上分配的。当函数返回时,这个内存可能不再有效,因此ptr
现在指向了一个无效的内存地址。这通常不是你所想要的。如果你需要在函数外部仍然能够访问这个值,你应该使用malloc
或其他内存分配函数来在堆上分配内存,并将地址赋给指针。然后,在适当的时候使用free
来释放这块内存。 -
如果你只是想传递一个参数的地址和值,而不打算修改指针本身,那么使用一级指针和值作为两个单独的参数通常更为简单和直接。
6、字符串操作
(1)sizeof()
-
sizeof()测量类型的大小,这里char是1个字节,但是定义了32个,因此为32,包括空格,
-
在这里sizeof()测得是指针的大小了,char 类型的指针。
-
int sizeof( type );
-
作用:返回变量占用内存的字节数
-
-
如下 lenArray 的值为50,字节没填满也算
char string1[50]; strcpy(string1, "hello world"); int lenArray = sizeof( string1 );
-
注:sizeof是一种单目运算符而非函数,但就使用方面而言理解为函数也不会犯错
(2)strlen()
-
strlen()是测量字符串的长度,读到'\0'就结束,不包括'\0'
-
int strlen( const char* str );
(3)strcpy()
-
strcpy()字符串的拷贝,将当前的字符串复制给所需要的参数(包括'\0'),但是会将之前参数的信息覆盖。
-
如果源字符串(包括空字符)的长度超过了目标字符串分配的内存大小,就会发生缓冲区溢出,这是一个常见的安全漏洞。
-
为了避免缓冲区溢出,可以考虑使用 strncpy 函数,它允许指定一个最大复制长度,从而避免溢出。但是,使用 strncpy 时需要小心处理字符串的结束标志,因为如果指定的长度小于源字符串长度,strncpy 不会自动在目标字符串的末尾添加空字符,这可能导致未定义的行为。
(4)strcmp()
-
strcmp是字符串的比较,如相等则返回值为0
-
为直观查看strcmp的作用,自己创建函数实现功能
//自己创建mystrcmp函数 int mystrcmp(const char * str1, const char * str2) { if(str1 == NULL || str2 == NULL) { perror("NULL"); exit(1); } while(*str1 != '\0' && *str2 != '\0') { if(*str1 > *str2) { return 1; } else if(*str1 < *str2) { return -1; } str1++; str2++; } return *str1 - *str2;//如果相等则是返回0,大于返回正数,小于返回负数。 }
(5)字符串常量区
-
字符串是存在字符串常量区,其中的数据不能被修改!!!!!,ptr存的是常量区的地址。因此若想获得其字符串,必须使用malloc分配内存。
-
正确改法如下:通过分配内存的数据里面存的是脏数据,因此必须使用memset清除脏数据!
-
注意后面还要用free()释放堆空间!
-
堆空间是计算机内存中的一个重要区域,用于动态分配和存储数据。通过指针来访问堆空间中的数据,并需要手动管理内存的分配和释放。
7、指针数组
(1)字符串数组
-
相当于二级指针或者是二维数组
-
字符串数组中的行列对应联系
(2)字符数组、字符指针指向常量、字符指针动态分配的区别
1. 字符数组
#define BUFFER_SIZE 32 //字符数组,地址在栈空间 char buffer[BUFFER_SIZE] = "hello world"; int size = sizeof(buffer);//32,等于数组字节长度 int len = strlen(buffer);//11,到'\0'的字符数 printf("size = %d\tlen = %d\n", size, len); strncpy(buffer, "257jiayou", BUFFER_SIZE); printf("buffer:%s\n", buffer);
2. 字符指针指向常量字符串
//字符指针指向字符串常量,地址在全局区 char* ptr = "hello world"; int size = sizeof(ptr);//8,等于指针字节长度 int len = strlen(ptr);//11,到'\0'的字符数 printf("size = %d\tlen = %d\n", size, len); //常量无法更改,越界报错 strncpy(ptr, "257jiayou", len); printf("ptr:%s\n", ptr);
3. 字符指针动态分配
//动态分配地址在堆空间 char* ptr = (char*)malloc(sizeof(char) * BUFFER_SIZE); if(ptr == NULL) { perror("malloc error"); exit(-1); } //清楚脏数据 memset(ptr, 0, BUFFER_SIZE); strncpy(ptr, "257jiayou", BUFFER_SIZE); printf("ptr:%s\n", ptr); free(ptr);
8、内存碎片
内存碎片是指在计算机内存管理过程中,内存被分配和释放后,形成的小块不连续的未使用内存区域的现象。这种现象会导致内存空间的利用效率降低。内存碎片主要分为两种类型:外部碎片和内部碎片。
1. 外部碎片
定义: 外部碎片发生在内存的分配和释放过程中,内存中会留下很多小的、非连续的空闲内存块。这些小块内存块之间可能被已经分配的内存块隔开,导致无法满足需要大块内存的分配请求。
示例: 假设内存中有一块大的空闲区域被分成了若干个小块,当一个程序需要分配一块较大的连续内存区域时,这些小块的空闲内存虽然总量足够,但它们之间的非连续性使得无法满足要求。
影响:
-
难以满足大内存块的分配请求。
-
导致内存使用效率降低。
解决方法:
-
内存压缩:将内存中的所有活动块移动到一起,以便形成一个大的连续空闲块。
-
内存分配策略:使用更先进的内存分配算法(如伙伴系统)来减少碎片化。
-
分区分配:将内存分成固定大小的区块,减少外部碎片。
2. 内部碎片
定义: 内部碎片发生在分配的内存块中,实际使用的内存小于分配的内存大小。由于内存的分配单位可能是固定大小的块,所以未被完全使用的空间会造成碎片。
示例: 假设内存分配单位是64字节,而一个程序只需要50字节的内存。分配了一个64字节的内存块,但实际只使用了其中的50字节,剩余的14字节则成为内部碎片。
影响:
-
造成内存的浪费,因为分配的内存块中有部分空间未被使用。
-
可能导致系统总内存使用效率降低。
解决方法:
-
调整分配单位:使用更合适的分配单位或动态调整块大小,以减少内部碎片。
-
使用更精细的分配策略:如内存池(memory pool)等机制来管理不同大小的内存需求。
3. 总结
内存碎片是内存管理中的一个重要问题,它会影响系统的性能和内存使用效率。为了优化内存管理,减少碎片的产生,许多操作系统和编程语言的运行时环境会采用各种策略和算法。理解内存碎片的原因和影响有助于设计更高效的内存管理系统。
二进制
1、编码方式
反码和补码是用于表示带符号整数的一种二进制编码方式,主要用于计算机系统中的算术运算。它们解决了二进制中表示负数的问题,并简化了二进制减法运算。以下是原码、反码、补码和移码的概念总结:
1. 原码(Sign-Magnitude Form)
-
定义:原码是直接使用最高位(符号位)表示符号,剩余位表示数值大小的二进制编码。
-
符号位:0表示正数,1表示负数。
-
数值位:直接使用绝对值的二进制表示。
-
-
例子:
-
+5
的原码:00000101
-
-5
的原码:10000101
-
-
特点:原码直观,但在进行加减运算时复杂,特别是负数运算。
2. 反码(One's Complement)
-
定义:反码是将原码的符号位保持不变,数值部分按位取反(0变1,1变0)的编码方式。
-
正数:反码与原码相同。
-
负数:反码为原码的数值部分按位取反。
-
例子:
-
+5
的反码:00000101
(与原码相同) -
-5
的反码:11111010
(数值部分取反)
-
-
特点:反码的表示方式解决了符号位的处理问题,但仍然存在零的两种表示形式(正零和负零)。
3. 补码(Two's Complement)
-
定义:补码是将原码的符号位保持不变,数值部分按位取反后加1的编码方式。
-
正数:补码与原码相同。
-
负数:补码为原码的数值部分按位取反后加1。
-
例子:
-
+5
的补码:00000101
(与原码相同) -
-5
的补码:11111011
(反码加1)
-
-
特点:补码的表示方式统一了零的表示(只有一种零:
00000000
),并且简化了减法运算。补码也是计算机系统中最常用的带符号数表示方式。
4. 移码(Excess-N or Offset Binary)
-
定义:移码是一种在补码基础上加上一个固定值(通常是2^(n-1))的编码方式,用于将所有的数值转换为非负数,从而简化某些运算。
-
正数:移码为补码加上偏移量。
-
负数:移码也是补码加上偏移量。
-
例子(假设偏移量为8,即加8):
-
+5
的移码:1101
(补码为0101
,加偏移量8) -
-5
的移码:0011
(补码为1011
,加偏移量8)
-
-
特点:移码在浮点数表示中常用,特别是在计算机硬件中用于标准化浮点数。
总结
-
原码:简单直观,但运算复杂。
-
反码:通过按位取反表示负数,但有两个零的问题。
-
补码:解决了反码的问题,简化了运算,是现代计算机中最常用的表示方式。
-
移码:用于浮点数表示,确保所有数值为非负数。
这些编码方式在计算机系统中起着关键作用,特别是在整数和浮点数运算中。
2、位运算
位运算(Bitwise Operations)是在二进制位(bit)层面进行的运算。位运算通常用于底层编程,尤其是涉及硬件、网络协议、加密算法等领域。以下是常见的位运算操作及其作用:
-
按位与(&):
-
规则:对应位置都为1时,结果为1,否则为0。
-
用途:用于掩码操作,提取特定位。
-
例子:
1010 & 1100 = 1000
-
-
按位或(|):
-
规则:对应位置只要有一个为1,结果为1。
-
用途:用于设置特定位。
-
例子:
1010 | 1100 = 1110
-
-
按位异或(^):
-
规则:对应位置不同则为1,相同则为0。
-
用途:用于交换值、检查差异。
-
例子:
1010 ^ 1100 = 0110
-
-
按位取反(~):
-
规则:每个位取反,1变0,0变1。
-
用途:用于反转所有位。
-
例子:
~1010 = 0101
-
-
左移(<<):
-
规则:将所有位向左移动指定的位数,右侧补0。
-
用途:快速乘以2的幂次。
-
例子:
1010 << 1 = 10100
(相当于乘以2)
-
-
右移(>>):
-
规则:将所有位向右移动指定的位数,左侧补0(或符号位)。
-
用途:快速除以2的幂次。
-
例子:
1010 >> 1 = 0101
(相当于除以2)
-
位运算广泛应用于:
-
性能优化:位运算通常比普通的算术运算更快。
-
网络和协议处理:处理和解析二进制协议或数据包。
-
加密与压缩:处理低级数据、提高数据操作效率。
通过位运算可以实现高效的低级数据操作,尤其适合需要精确控制内存和速度的场景。
排序
1、相关知识点
(1)数组
-
数组是一种数据结构,它包含一系列相同类型的元素,这些元素在内存中连续存储。
-
数组有一个固定的大小,一旦声明,就不能改变。
-
数组有一个名字,这个名字是一个常量指针,指向数组的第一个元素的地址。
-
数组名本身不能被修改,即不能让它指向别的地址。
(2)指针
-
指针是一个变量,它存储的是一个内存地址,这个地址指向的是另一个变量的值。
-
指针可以指向任何类型的变量,包括数组。
-
指针可以被重新赋值,指向别的内存地址。
-
指针可以进行各种算术运算,比如加减操作,来指向不同的内存地址。
(3)数组和指针的使用
-
当你需要固定大小的一系列同类型数据时,使用数组。
例如,如果你需要存储10个整数,你可以定义一个大小为10的整数数组。
-
当你需要动态地改变数据的大小,或者在函数之间传递数据时,使用指针。
例如,你可能需要在一个函数中创建一个数组,然后在另一个函数中使用这个数组。在这种情况下,你可以使用指针来传递数组的地址,而不是整个数组。
-
在函数参数中传递大型数组时,通常使用指针。
如果直接传递数组,实际上传递的是整个数组的一份拷贝,这可能会消耗大量的内存和处理器时间。而传递指针则只会传递一个内存地址,效率更高。
(4)注意字符串常量区引起的段错误。
-
如果你想要一个可以修改的字符串,你应该使用动态内存分配(例如 malloc() 或 calloc()),或者定义一个字符数组在栈上(例如 char str[] = "hello world";)。这样,str 就会指向一块可以修改的内存区域。
-
字符串字面值(如 "hello world")通常存储在程序的只读内存段中。这意味着你不能修改这些字符串的内容。
(5)函数传出参数和传入参数
-
传入参数(Input Parameters)
传入参数是在调用函数时传递给函数的值或变量。这些参数允许函数使用外部提供的数据来执行其任务。传入参数可以是常量、变量、表达式的结果,或者是从其他函数返回的值。
用法:
在函数定义时,在函数名后面的括号内声明传入参数的类型和名称。 在函数调用时,按照函数定义中参数的顺序和类型提供相应的值或变量。
-
传出参数(Output Parameters) 传出参数通常是通过指针或引用传递的参数,允许函数修改调用者提供的变量的值。这些参数用于将结果或状态信息从函数返回给调用者。
用法:
在函数定义时,声明传出参数的类型和名称,并使用指针或引用传递它们。 在函数调用时,传递变量的地址或引用给函数,以便函数可以修改该变量的值。
(6) 计算机内存分配区域(一定要牢记)
-
(1)栈:向下扩展的内存。 系统栈(系统自带)和函数栈(自己创建),栈是会放满的。
栈里面的局部变量: { int a;//左括号开始,右括号结束。 }
-
(2)堆:也称为动态存储区。这部分内存用于动态分配,向上扩展的内存。堆的空间要比栈大得多:由用户自己申请,并且由用户自己释放(若不释放,造成内存泄露)
寿命是由用户自己决定的
-
(3)静态全局区:静态变量和全局变量
1、全局变量:能被所有的程序可见
生命周期:当程序结束时被销毁(少用全局变量)
2、静态变量:static修饰
修饰局部变量的特点:在函数结束时不会被系统回收,只在程序结束时被销毁; 只初始化一次
修饰全部变量的特点:只对本文可见
-
(4)文字常量区:字符串常量
常量的特点:不可修改
-
(5) 代码段 :也称为文本区,存储程序的二进制代码。这部分内存是只读的,防止程序在运行时意外地修改其指令。
2、冒泡排序
(1)定义
-
冒泡排序(Bubble Sort)是一种简单的排序算法,它重复地遍历要排序的数列,一次比较两个元素,一轮下来能将最大的数排到后面。
(2)bubbleSort函数排序思路
-
思路1:按定义规则。
int bubbleSort01(int *nums, int numSize) { #if 0 for (int idx = 0; idx < numSize; idx++) { for (int begin = 1; begin < numSize - idx - 1; begin++)// { if (nums[begin - 1] > nums[begin]) { /*交换*/ swapNum(&nums[begin - 1], &nums[begin]); } } } #endif for (int end = numSize; end > 0; end--) { for (int begin = 1; begin < end; begin++) { if (nums[begin - 1] > nums[begin]) { /*交换*/ swapNum(&nums[begin - 1], &nums[begin]); } } } return 0; }
-
思路2:若已经提前排序成功,为避免剩余轮数继续遍历,则定义一个标志位。
(1) 定义标志位sorted
(2)每开启一轮标志位为1
(3)内部交换完标志位为0
(4)如果内部没有发生交换那就说明总体已经全部排好了,不需要其他的轮数再去遍历,避免了内存消耗。
(5)没发生交换,则标志位为1,进入判断,直接break结束循环。
/*升序*/ /*记录一下是不是排好序呢*/ int bubbleSort02(int *nums, int numSize) { int sorted = 0;//标记 for (int end = numSize; end > 0; end--)//轮数循环 { sorted = 1; for (int begin = 1; begin < end; begin++) //一轮结束,最大值就在最后 { if (nums[begin - 1] > nums[begin]) { /*交换*/ swapNum(&nums[begin - 1], &nums[begin]); sorted = 0; } } /*已经排好序了*/ if (sorted == 1) { break; } } return 0; }
(3) 全部代码
#include <stdio.h> #include <string.h> #define BUFFER_SIZE 100 /*函数参数传递是按值传递的,形参为传出函数,形参定义成指针,这样就可以修改形参,*/ static int swapNum(int *val1, int *val2) { int tmpVal = *val1; *val1 = *val2; *val2 = tmpVal; } /*升序*/ int bubbleSort01(int *nums, int numSize) { #if 0 for (int idx = 0; idx < numSize; idx++) { for (int begin = 1; begin < numSize - idx - 1; begin++)// { if (nums[begin - 1] > nums[begin]) { /*交换*/ swapNum(&nums[begin - 1], &nums[begin]); } } } #endif for (int end = numSize; end > 0; end--) { for (int begin = 1; begin < end; begin++) { if (nums[begin - 1] > nums[begin]) { /*交换*/ swapNum(&nums[begin - 1], &nums[begin]); } } } return 0; } /*升序*/ /*记录一下是不是排好序呢*/ int bubbleSort02(int *nums, int numSize) { int sorted = 0;//标记 for (int end = numSize; end > 0; end--) { sorted = 1; for (int begin = 1; begin < end; begin++) { if (nums[begin - 1] > nums[begin]) { /*交换*/ swapNum(&nums[begin - 1], &nums[begin]); sorted = 0; } } /*已经排好序了*/ if (sorted == 1) { break; } } return 0; } int printNums(int *nums, int numSize) { for (int idx = 0; idx < numSize; ++idx) { printf("%d\t", nums[idx]); } printf("\n"); } int main() { // int nums[BUFFER_SIZE],加上BUFFER_SIZE就是固定分配大小,不加就是数据写多少分配多少。 int nums[] = {1, 45, 2, 34, 7, 87, 3, 9, 12, 99}; int len = sizeof(nums) / sizeof(nums[0]); bubbleSort02(nums, len); printNums(nums, len); return 0; }
3、选择排序
(1)定义
-
通过一次排序将待排序的数据分割成独立的两部分,其中一部分的所有数据都比另一部分的所有数据都要小,然后再按此方法对这两部分数据分别进行快速排序,整个排序过程可以递归进行,以此达到整个数据变成有序序列。
-
本次代码思想:先定义一个POS为该数据的第一个,然后遍历后面的数据并与POS比较,如果遍历到最小的,这将与POS发生交换,此时第一个是最小的。随后POS位置向前进一个,以此类推。
(2)selectSort的排序思路
-
思想1:按定义中的思想进行。
int selectSort01(int *nums, int numSize) { int mini = 0; int minPos = 0; for (int pos = 0; pos < numSize; pos++) { mini = nums[pos];//控制pos for (int idx = pos + 1; idx < numSize; idx++) { if (nums[idx] < mini) { mini = nums[idx]; minPos = idx; } } /* 找到之后的最小值. */ if (nums[pos] > mini) { swapNum(&(nums[pos]), &(nums[minPos])); } } return 0; }
-
思想2:
(1)增加一个数组指定范围接口。
(2)思想跟思想1差不多,增加的接口也是为了将“找剩下的元素中最小的数”给分离出一个函数出来。
(3)selectSort02中是只需要控制POS往前移动即可
/*找到数组指定范围的最小值*/ static int findNumRangMinVal(int *nums, int begin, int end, int *mini, int *minPos) { int min = nums[begin]; for (int pos = begin; pos < end; pos++) { if (nums[pos] < min) { min = nums[pos];//覆盖 *minPos = pos; } } /*解引用*/ *mini = min; return 0; }
int selectSort02(int *nums, int numSize) { int mini = 0; int minPos = 0; for (int pos = 0; pos < numSize; pos++) { findNumRangMinVal(nums, pos, numSize, &mini, &minPos); /* 找到之后的最小值 */ if (nums[pos] > mini) { swapNum(&(nums[pos]), &(nums[minPos])); } } } int printNums(int *nums, int numSize) { for (int idx = 0; idx < numSize; ++idx) { printf("%d\t", nums[idx]); } printf("\n"); }
(3)全部代码
#include <stdio.h> #include <string.h> static int swapNum(int *val1, int *val2) { int tmpVal = *val1; *val1 = *val2; *val2 = tmpVal; } /* 找到数组的最小值 */ int findNumMinVal(int *nums, int numSize, int *ppos) { int mini = nums[0]; for (int pos = 0; pos < numSize; pos++) { if (nums[pos] < mini) { mini = nums[pos]; *ppos = pos; } } return mini; } /*找到数组指定范围的最小值*/ static int findNumRangMinVal(int *nums, int begin, int end, int *mini, int *minPos) { int min = nums[begin]; for (int pos = begin; pos < end; pos++) { if (nums[pos] < min) { min = nums[pos]; *minPos = pos; } } *mini = min; return 0; } int selectSort01(int *nums, int numSize) { int mini = 0; int minPos = 0; for (int pos = 0; pos < numSize; pos++) { for (int idx = pos + 1; idx < numSize; idx++) { if (nums[idx] < mini) { mini = nums[idx]; minPos = idx; } } /* 找到之后的最小值. */ if (nums[pos] > mini) { swapNum(&(nums[pos]), &(nums[minPos])); } } return 0; } int selectSort02(int *nums, int numSize) { int mini = 0; int minPos = 0; for (int pos = 0; pos < numSize; pos++) { findNumRangMinVal(nums, pos, numSize, &mini, &minPos); /* 找到之后的最小值 */ if (nums[pos] > mini) { swapNum(&(nums[pos]), &(nums[minPos])); } } } int printNums(int *nums, int numSize) { for (int idx = 0; idx < numSize; ++idx) { printf("%d\t", nums[idx]); } printf("\n"); } int main() { //int pos = 0; int nums[] = {4, 45, 2, 34, 7, 87, 3, 9, 12, 99}; int len = sizeof(nums) / sizeof(nums[0]); selectSort02(nums, len); printNums(nums, len); return 0; }
4、插入排序
(1)定义
-
通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。
-
本次代码实现思想:先定义一个copynum。从头开始比较,如果发现当前数比后一个数要大,则将后一个数赋值给copynum,当前数直接将后一个数覆盖。随后,当前数的前面数如果比copynum要大,则需要后移,如果小于copynum,则直接插入这个位置,copynum是往前遍历的,直到copynum找到最小的位置进行插入。
(2)insertSort的排序思路
-
插入排序默认第一个元素已经排好序了,目的就是将第一个跟当前结点比较,让后将最小的赋值给copyNum
/*插入排序*/ int insertSort(int *nums, int numSize) { /*插入排序默认第一个元素已经排好序了*/ for (int idx = 1; idx < numSize; idx++) { int copyNum = nums[idx]; /*索引备份*/ int preIdx = idx - 1; while (nums[preIdx] > copyNum && preIdx >= 0) { /*后移*/ nums[preIdx + 1] = nums[preIdx];//覆盖 preIdx--; } /*nums[preIdx] <= copyNum || preIdx < 0*/ nums[preIdx + 1] = copyNum; } return 0; }
(3)插入排序优化
-
基于二分搜索
/*找到copynum插入的位置*/ static int searchInsertPos(int *nums, int numsSize, int target) { int left = 0; int right = numsSize - 1; int mid = 0; while (left <= right) { mid = (left + right) / 2; if (nums[mid] <= target) { left = mid + 1; } else { right = mid - 1; } } return left; }
(4)全部代码
#include <stdio.h> #include <string.h> static int swapNum(int *val1, int *val2) { int tmpVal = *val1; *val1 = *val2; *val2 = tmpVal; } /*找到copynum插入的位置*/ static int searchInsertPos(int *nums, int numsSize, int target) { int left = 0; int right = numsSize - 1; int mid = 0; while (left <= right) { mid = (left + right) / 2; if (nums[mid] <= target) { left = mid + 1; } else { right = mid - 1; } } return left; } /*插入排序*/ int insertSort(int *nums, int numSize) { /*插入排序默认第一个元素已经排好序了*/ int insertPos = 0; for (int idx = 1; idx < numSize; idx++) { int copyNum = nums[idx]; int prevIdx = idx - 1; /* 找到要排序元素要插入的位置 O(logN) */ insertPos = searchInsertPos(nums, idx, copyNum); #if 0 while (nums[prevIdx] > copyNum && prevIdx >= 0) { nums[prevIdx + 1] = nums[prevIdx]; prevIdx--; } // nums[prevIdx] <= copyNum && prevIdx = -1 nums[prevIdx + 1] = copyNum; #else /* 数组迁移, 从后向前迁移. */ for (int jdx = idx; jdx > insertPos; jdx--) { nums[jdx] = nums[jdx - 1]; } nums[insertPos] = copyNum; #endif } return 0; } int printNums(int *nums, int numSize) { for (int idx = 0; idx < numSize; ++idx) { printf("%d\t", nums[idx]); } printf("\n"); } int main() { // int pos = 0; int nums[] = {4, 45, 2, 34, 7, 87, 3, 9, 12, 99}; int len = sizeof(nums) / sizeof(nums[0]); insertSort(nums, len); printNums(nums, len); return 0; }
5、快速排序
(1)定义
-
基本思想是采用分治策略
-
分治策略:将一个复杂的问题分解为两个或多个相同或相似的子问题,递归地解决这些子问题,最后将子问题的解合并起来得到原问题的解。
-
基准元素:在快速排序中,选择一个元素作为基准,然后将数组分为两部分,一部分的元素都比基准小,另一部分的元素都比基准大。
-
(2)排序思路
-
思想:采用“分治”的思想,对于一组数据,选择一个基准元素(pivot),通常选择第一个或最后一个元素,通过第一轮扫描,比pivot小的元素都在pivot左边,比pivot大的元素都在pivot右边,再有同样的方法递归排序这两部分,直到序列中所有数据均有序为止。
-
首先记录基准值,默认定义基准值为所取范围的第一个值,即 int pivot = nums[begin];这个值单独出来的。
-
使用while()循环,终止条件begin = end;此循环是个大循环,用来控制轮数。
-
内部循环1-while (begin < end)
-
先从左往右排,这是判断末尾的值。即从末尾开始,即若nums[end]的值比基准值要小,则将nums[end]的值赋给开头nums[begin],让后begin++,准备存储第二个比基准值小的值,并退出循环,开始执行内部循环2。
-
若nums[end]比基准值要大,则值的位置不动,并end--。开始下一轮循环,直到跳出循环执行内部循环2,或者begin = end 结束循环。
-
-
内部循环2-while (begin < end)
-
这是从右往左排,这是判断开头的值,即从起始位置,若nums[begin]的值比基准值要大,则将nums[begin]的值给end,并且end--,然后结束循环,开始进去内部循环1判断end 的值。
-
若nums[end]的值比基准值要小,则值的位置不动,并begin++。开始下一轮循环判断,直到跳出循环执行内部循环1,或者begin = end 结束循环。
-
-
-
一轮大循环下来,比基准值小的在左边,比基准大的在右边,但是左边和右边的还是乱的,因此再次调用该函数对左边和右边再次进行快排。
-
总结:
-
两个内部循环一个是判断begin的迁移,一个判断判断end的迁移。
-
两个迁移一个是保证位置不变,一个是覆盖后面end 或者begin的值,实现迁移和分在基准值的两边。
-
(3)全部代码
注意:需要对begin和end的初始位置进行备份,因为后面几轮的排序,还需要用到起始和末尾的位置。
#include <stdio.h> #include <stdlib.h> #include <string.h> static int innerQuickSort(int *nums, int begin, int end) { if (begin >= end) { return 0; } /*记录基准值*/ int pivot = nums[begin]; /*备份begin,end*/ int tmpBegin = begin; int tmpEnd = end; while (begin < end) { while (begin < end) { /*从右往左*/ if (nums[end] >= pivot) { end--; } else { //nums[begin++] = nums[end];//与下面等价 nums[begin] = nums[end]; begin++; break; } } while (begin < end) { /*从左往右*/ if (nums[begin] > pivot) { nums[end--] = nums[begin]; break; } else { begin++; } } } /*退出这个条件 begin== end*/ nums[begin] = pivot;//再次将begin的值给pivot innerQuickSort(nums, tmpBegin, begin - 1); innerQuickSort(nums, begin + 1, tmpEnd); return 0; } /*快速排序*/ int quickSort(int *nums, int numSize) { if (nums == NULL) { return 0; } innerQuickSort(nums, 0, numSize - 1); } int printNums(int *nums, int numSize) { for (int idx = 0; idx < numSize; ++idx) { printf("%d\t", nums[idx]); } printf("\n"); } int main() { // int pos = 0; int nums[] = {31, 47, 25, 16, 4, 35,38}; int len = sizeof(nums) / sizeof(nums[0]); quickSort(nums, len); printNums(nums, len); return 0; }
6、二分搜索
(1)定义
-
二分搜索(也称为二分查找)是一种在有序数组中查找特定元素的搜索算法。其基本思想是通过不断地将搜索范围缩小一半来找到目标元素。
-
注意的是有序数组,因此数组若是无序的,先开始排序后在调用二分搜索。
(2)搜索思路
-
确定搜索范围:首先,确定有序数组的范围,即左边界和右边界。
-
计算中间位置:计算搜索范围的中间位置。通常,中间位置可以通过将左边界和右边界相加然后除以2来得到。
-
比较中间元素:将目标元素与中间位置的元素进行比较。
-
比较原则:
-
如果中间值num[minIndex] 比目标值大,说明目标在中间值的右边,此时将end的位置更新到中间值的前一个位置,即end = minIndex - 1;
-
如果中间值num[minIndex] 比目标值小,说明目标在中间值的左边,此时将begin的位置更新到中间值的后一个位置,即begin = minIndex + 1;
-
直到begin > end就结束循环,即找到该目标。
-
/*二分查找*/ int binarySearch(int *num, int numSize, int target) { int begin = 0; int end = numSize - 1; // 不减就踩内存 int midIndex = 0; while (begin <= end) { /*跟新中间位置*/ midIndex = (begin + end) >> 1; if (num[midIndex] > target) { end = midIndex - 1; } else if (num[midIndex] < target) { begin = midIndex + 1; } else { return midIndex; } } return -1; }
(3)全部代码
-
这里使用快速排序进行排序后,在进行二分查找。
#include <stdio.h> #include <stdlib.h> /*二分查找*/ int binarySearch(int *num, int numSize, int target) { int begin = 0; int end = numSize - 1; // 不减就踩内存 int midIndex = 0; while (begin <= end) { /*跟新中间位置*/ midIndex = (begin + end) >> 1;//mid = (begin + end) / 2 可能造成向下取整 if (num[midIndex] > target) { end = midIndex - 1; } else if (num[midIndex] < target) { begin = midIndex + 1; } else { return midIndex; } } return -1; } static int innerQuickSort(int *nums, int begin, int end) { if (begin >= end) { return 0; } /*记录基准值*/ int pivot = nums[begin]; /*备份begin,end*/ int tmpBegin = begin; int tmpEnd = end; while (begin < end) { while (begin < end) { /*从右往左*/ if (nums[end] >= pivot) { end--; } else { nums[begin++] = nums[end]; break; } } while (begin < end) { /*从左往右*/ if (nums[begin] > pivot) { nums[end--] = nums[begin]; break; } else { begin++; } } } /*退出这个条件 begin== end*/ nums[begin] = pivot; innerQuickSort(nums, tmpBegin, begin - 1); innerQuickSort(nums, begin + 1, tmpEnd); return 0; } /*快速排序*/ int quickSort(int *nums, int numSize) { if (nums == NULL) { return 0; } innerQuickSort(nums, 0, numSize - 1); } int printNums(int *nums, int numSize) { for (int idx = 0; idx < numSize; ++idx) { printf("%d\t", nums[idx]); } printf("\n"); } int main() { int nums[] = {31, 47, 25, 16, 4, 35, 38}; int len = sizeof(nums) / sizeof(nums[0]); quickSort(nums, len); printNums(nums, len); int index = binarySearch(nums, len, 35); printf("index1: %d\n", index); index = binarySearch(nums, len, 4); printf("index2: %d\n", index); index = binarySearch(nums, len, 47); printf("index3: %d\n", index); index = binarySearch(nums, len, 5); printf("index4: %d\n", index); return 0; }
数据结构
1、结构体
(1)结构体的使用
1.1 大小计算
-
结构体占所有字节的大小,注意是所有字节
-
下面图片中大小:4 + 4 + 32 = 40
1.2 字节对齐
-
默认最大的类型对齐,最大类型为int型
-
每次都是以4个字节进行分配,不满4个字节都分配4个字节。图中按理论为38,由于字节对齐分配了40个字节,多余出2个字节。
1.3 清除脏数据
-
memset的第一个参数是void *类型的,因此要做取地址操作。
memset(&stu, 0, sizeof(stu));
1.4 结构体的赋值
-
数组名是数组的首地址,常量不可以更改。正确做法是使用strcpy函数对字符串进行复制。
stu.name = "taowanbao";//error strcpy(stu.name,"taowanbao");
(2)typedef的作用
-
typedef相当于给结构体另取一个名字。
-
底部为结构体新名字。
-
没有使用typedef之前为struct StuInfo stu;
-
使用typedef之后为StuInfo stu;
(3)结构体数组
3.1 定义
StuInfo info[SIZE]; int len = sizeof(info); printf("len:%d\n", len); /* 清除脏数据 */ memset(info, 0, sizeof(info));
3.2 结构体数组的打印
/* 值传递 : 浪费资源 */ /* 地址传递 : 节省资源 */ int printStruct(StuInfo *pInfo, int size) { int ret = 0; if (pInfo == NULL) { return 0; } for (int idx = 0; idx < size; idx++) { /* 结构体指针取值使用的是 -> */ printf("age:%d\t, sex:%c\t, name:%s\t, height:%d\n", pInfo[idx].age, pInfo[idx].sex, pInfo[idx].name, pInfo[idx].height); } return ret; }
(4)指针结构体数组
-
stu.address是定义了指针类型(char *address),一定要对其内存进行分配,不然会报段错误。
StuInfo stu; memset(&stu, 0, sizeof(stu)); // stu.address = "jiangshuyancheng"; stu.address = (char *)malloc(sizeof(char) * BUFFER_SIZE); if (stu.address == NULL) { perror("malloc error"); exit(-1); } strcpy(stu.address, "jiangshuyancheng");
(5)全部代码段
#include <stdio.h> #include <string.h> #include <stdlib.h> #define BUFFER_SIZE 32 #define SIZE 3 /* typedef 取别名 */ /* 字节对齐 */ typedef struct StuInfo { int age; // 4 char sex; // 1 #if 0 char name[BUFFER_SIZE]; // 32 #else char * address; #endif char height; // 1 } StuInfo; #if 0 /* 值传递 : 浪费资源 */ /* 地址传递 : 节省资源 */ int printStruct(StuInfo *pInfo, int size) { int ret = 0; if (pInfo == NULL) { return 0; } for (int idx = 0; idx < size; idx++) { /* 结构体指针取值使用的是 -> */ printf("age:%d\t, sex:%c\t, name:%s\t, height:%d\n", pInfo[idx].age, pInfo[idx].sex, pInfo[idx].name, pInfo[idx].height); } return ret; } #endif int main() { #if 0 /* 结构体的基本使用 */ StuInfo stu1; int len = sizeof(stu1); printf("len:%d\n", len); len = sizeof(struct StuInfo); printf("len:%d\n", len); /* 清空脏数据 */ memset(&stu1, 0, sizeof(stu1)); /* 赋值 */ #if 0 /* 数组名是数组的首地址, 常量不可以更改 */ stu1.name = "zhangsan"; #endif strcpy(stu1.name, "zhangsan"); stu1.age = 20; stu1.height = 60; stu1.sex = 'm'; /* 写一个函数, 打印该结构体 */ printStruct(&stu1); #endif #if 0 StuInfo info[SIZE]; int len = sizeof(info); printf("len:%d\n", len); /* 清除脏数据 */ memset(info, 0, sizeof(info)); info[0].age = 20; #if 0 // info[0].name = "lisi" #else strcpy(info[0].name, "lisi"); #endif info[0].sex = 'm'; info[0].height = 70; info[1].age = 30; #if 0 // info[0].name = "lisi" #else strcpy(info[1].name, "zhangsan"); #endif info[1].sex = 'f'; info[1].height = 50; info[2].age = 40; #if 0 // info[0].name = "lisi" #else strcpy(info[2].name, "wangwu"); #endif info[2].sex = 'm'; info[2].height = 100; #if 0 for(int idx = 0; idx < SIZE; idx++) { printStruct(&(info[idx])); } #else printStruct(info, sizeof(info) / sizeof(info[0])); #endif #endif #if 1 StuInfo stu; memset(&stu, 0, sizeof(stu)); // stu.address = "jiangshuyancheng"; stu.address = (char *)malloc(sizeof(char) * BUFFER_SIZE); if (stu.address == NULL) { perror("malloc error"); exit(-1); } strcpy(stu.address, "jiangshuyancheng"); stu.age = 22; stu.height = 65; stu.sex = 'm'; printf("age:%d\t, sex:%c\t, address:%s\t, height:%d\n", stu.age, stu.sex, stu.address, stu.height); #endif return 0; }
2、动态数组
1. 静态数组
-
数组分类:字符数组;整形数组;字符串数组;结构体数组。
-
优点:
-
“静态”数组的大小在编译期间就确定;
-
内存大小已经分配确定的大小;
-
其内存在使用结束后由计算机自动释放,效率高。
-
-
静态数组的局限性1:由于分配空间的大小是固定的,所以当需要插入的数据大于容量时,再插入会导致访问非法地址,这种情况会导致栈粉碎。
-
静态数组的局限性2:由于分配空间的大小是固定的,所以当需要插入的数据远小于分配的空间时,会造成内存空间的浪费。
2. 动态数组
-
定义
-
/* 动态数组结构体 */ struct DynamicArray { /* 数据 */ ELEMENTTYPE * data; /* 元素个数 */ int size; /* 容量大小 */ int capacity; };
-
-
动态数组通常使用连续的内存空间来存储元素,这使得访问数组中的元素变得非常高效(通常是O(1)的时间复杂度)。然而,当数组需要增长时,则分配更大的内存块,并将现有元素复制到新的内存位置。这个操作可能是昂贵的,但由于它发生的频率相对较低,因此动态数组仍然是一种非常高效的数据结构。
-
动态数组的实现通常涉及到以下关键操作:
-
初始化:为数组分配初始的内存空间。
-
/* 动态数组初始化 */ int dynamicArrayInit(DynamicArray *pArray, int capacity) { int ret = 0; if (pArray == NULL) { printf("null ptr error\n"); return NULL_PTR; } if (capacity <= 0) { capacity = DEFAULR_CAPACITY; } /* 分配堆空间 */ pArray->data = (ELEMENTTYPE *)malloc(sizeof(ELEMENTTYPE) * capacity); if (pArray->data == NULL) { printf("malloc error\n"); return MALLOC_ERROR; } /* 清除脏数据 */ memset(pArray->data, 0, sizeof(ELEMENTTYPE) * capacity); /* 初始化的时候, 元素个数为0 */ pArray->size = 0; pArray->capacity = capacity; return ON_SUCCESS; }
-
-
扩容:当数组的空间不足以容纳更多元素时,分配更大的内存空间,并将现有元素复制到新的内存位置。
-
扩容操作的步骤
-
扩容之后开始将数据迁移。
-
先备份数据 tmp = ptr
-
申请更大的容量ptr
-
将备份的数据放在ptr内存中
/* 静态函数: 只在本源文件中使用 */
static int expandDynamicArrayCapacity(DynamicArray *pArray)
{
int ret = 0; // 初始化返回值为0,表示成功
/* 1. 数据备份 */
// 备份当前数组的数据指针,便于后续的数据迁移
ELEMENTTYPE * tmpData = pArray->data;
/* 2. 需要扩大的容量 */
// 计算需要扩大的新容量。这里采用1.5倍的策略(pArray->capacity >> 1 相当于 pArray->capacity / 2)
int needExpandCapacity = pArray->capacity + (pArray->capacity >> 1);
// 分配新内存空间给数组,并更新数组的数据指针
pArray->data = (ELEMENTTYPE *)malloc(sizeof(ELEMENTTYPE) * needExpandCapacity);
if (pArray->data == NULL) // 判断内存分配是否成功
{
return MALLOC_ERROR; // 如果分配失败,返回错误代码
}
/* 清除脏数据 */
// 使用 memset 函数将新分配的内存空间清零,防止未初始化数据带来的潜在问题
memset(pArray->data, 0, sizeof(ELEMENTTYPE) * needExpandCapacity);
/* 3. 数据迁移 */
// 将旧数据从备份指针 tmpData 迁移到新分配的数组空间中
for (int idx = 0; idx < pArray->size; idx++)
{
pArray->data[idx] = tmpData[idx];
}
/* 4. 释放内存 */
// 释放旧的内存空间,防止内存泄漏
if (tmpData != NULL)
{
free(tmpData);
tmpData = NULL; // 释放后将指针置为 NULL,避免野指针的出现
}
/* 5. 更新数组的属性 */
// 更新数组的容量属性,记录新的容量大小
pArray->capacity = needExpandCapacity;
return ret; // 返回0,表示扩容操作成功
}
-
获取数组的元素个数
-
/* 获取数组的元素个数 */ int dynamicArrayGetSize(DynamicArray *pArray, int *pSize) { if (pArray == NULL) { return NULL_PTR; } if (pSize != NULL) { /* 解引用 */ *pSize = pArray->size; } return ON_SUCCESS; }
-
-
缩容:如果数组的空间远远超过当前需要的空间,可能会释放部分内存以节省空间。
-
与扩容执行相反对操作
-
/* 缩容 */
if (pArray->size < pArray->capacity - (pArray->capacity >> 1))
{
shrinkDynamicArrayCapacity(pArray);
}
for (int idx = pos; idx < pArray->size - 1; idx++)
{
pArray->data[idx] = pArray->data[idx + 1];
}
插入:
-
默认插到数组的默认位置(结尾)
-
/* 动态数组插入元素 (默认插到数组末尾位置) */ int dynamicArrayInsertData(DynamicArray *pArray, ELEMENTTYPE data) { return dynamicArrayAppointPosInsertData(pArray, pArray->size, data); }
-
在数组的指定位置插入一个元素。如果需要,可能会涉及到扩容操作。
/* 动态数组在指定位置插入元素 */
int dynamicArrayAppointPosInsertData(DynamicArray *pArray, int pos, ELEMENTTYPE data)
{
int ret = 0;
/* 判空 */
if (pArray == NULL)
{
printf("null ptr error\n");
return NULL_PTR;
}
/* 判断位置是否合法 */
if (pos < 0 || pos > pArray->size)
{
return INVALID_ACCESS;
}
/* 空间不足预警算法是: 元素个数的1.5倍 > 数组容量 */
if (pArray->size + (pArray->size >> 1) > pArray->capacity)
{
/* 扩容 */
expandDynamicArrayCapacity(pArray);
}
/* 从后往前移动 */
for (int idx = pArray->size; idx > pos; idx--)
{
pArray->data[idx] = pArray->data[idx - 1];
}
/* 赋值 */
pArray->data[pos] = data;
/* 更新数组属性 */
(pArray->size)++;
return ret;
}
-
-
删除:
-
从数组的默认位置删除元素
-
/* 动态数组删除元素 (默认删除数组末尾位置) */ int dynamicArrayDeleteData(DynamicArray *pArray) { return dynamicArrayAppointPosDeleteData(pArray, pArray->size - 1); } //缩容 static int shrinkDynamicArrayCapacity(DynamicArray *pArray) { /* 1. 数据备份 */ ELEMENTTYPE * tmpData = pArray->data; /* 2. 开辟一块更小的空间 */ int needShrinkCapacity = pArray->capacity - (pArray->capacity >> 1); pArray->data = (ELEMENTTYPE *)malloc(sizeof(ELEMENTTYPE) * needShrinkCapacity); if (pArray->data == NULL) { return MALLOC_ERROR; } /* 清除脏数据 */ memset(pArray->data, 0, sizeof(ELEMENTTYPE) * needShrinkCapacity); /* 3. 数据迁移 */ for (int idx = 0; idx < pArray->size; idx++) { pArray->data[idx] = tmpData[idx]; } /* 4. 释放内存 */ if (tmpData != NULL) { free(tmpData); tmpData = NULL; } /* 5. 更新数组属性 */ pArray->capacity = needShrinkCapacity; return ON_SUCCESS; }
-
从数组的指定位置删除一个元素。如果需要,可能会涉及到缩容操作。
-
/* 动态数组在指定位置删除元素 */ int dynamicArrayAppointPosDeleteData(DynamicArray *pArray, int pos) { /* 判空 */ if (pArray == NULL) { return NULL_PTR; } /* 判断位置合法性 */ if (pos < 0 || pos >= pArray->size) { return INVALID_ACCESS; } /* 缩容 */ if (pArray->size < pArray->capacity - (pArray->capacity >> 1)) { shrinkDynamicArrayCapacity(pArray); } for (int idx = pos; idx < pArray->size - 1; idx++) { pArray->data[idx] = pArray->data[idx + 1]; } /* 更新数组的属性 */ (pArray->size)--; return ON_SUCCESS; }
-
-
访问:通过索引访问数组中的元素。这是一个非常高效的操作,通常是O(1)的时间复杂度。
-
-
销毁
-
/* 销毁动态数组 */ int dynamicArrayDestroy(DynamicArray *pArray) { if (pArray == NULL) { return NULL_PTR; } if (pArray->data != NULL) { free(pArray->data); pArray->data = NULL; } return ON_SUCCESS; }
-
3、链表
链表是一种基本的数据结构,用于在计算机科学中组织数据。它由一系列节点组成,每个节点包含两个部分:一个存储数据的字段和一个指向下一个节点的指针。链表有不同的变体,包括单向链表、双向链表和循环链表。以下是链表的详细介绍以及它的优点和缺点:
-
链表的基本概念
-
单向链表 (Singly Linked List):
-
每个节点有两个部分:数据字段和指向下一个节点的指针。
-
第一个节点称为头节点(head),最后一个节点的指针为空(null),表示链表的结束。
-
-
双向链表 (Doubly Linked List):
-
每个节点有三个部分:数据字段、指向下一个节点的指针和指向前一个节点的指针。
-
头节点的前一个指针为空,尾节点的下一个指针为空。
-
-
循环链表 (Circular Linked List):
-
链表的最后一个节点指向第一个节点,从而形成一个循环。
-
可以是单向或双向循环链表。
-
-
-
链表的优点
-
动态大小:链表的大小可以在运行时动态调整,不需要预先定义大小。与数组相比,链表可以更灵活地管理内存。
-
插入和删除操作高效:在链表中插入和删除节点的操作非常高效,特别是当你已经持有对相关节点的引用时。这些操作的时间复杂度是 O(1),因为只需更新相应的指针即可。
-
避免内存碎片:链表在内存中可以分散存储,不需要连续的内存块,这减少了内存碎片的风险。
-
-
链表的缺点
-
空间开销:每个节点除了存储数据外,还需要额外的指针来引用其他节点,这增加了空间开销。特别是双向链表,每个节点有两个指针,开销更大。
-
随机访问困难:链表不支持直接访问元素,访问链表中的第 n个元素需要从头节点开始遍历,时间复杂度为 O(n)。相比于数组,链表在随机访问方面效率较低。
-
额外的操作复杂性:在链表中,操作节点的插入和删除可能需要更新多个指针(特别是在双向链表中),这增加了实现的复杂性。
-
缓存局部性差:链表中的节点在内存中可能不是连续存储的,这可能导致缓存未命中,从而影响访问速度。数组则在内存中是连续存储的,能够利用缓存优势。
(1)概念
struct LinkNode
{
/* 数据域 */
ELEMENTTYPE data;
/* 指针域 */
struct LinkNode *next;
};
struct LinkList
{
/* 链表的长度(结点个数) */
int size;
/* 链表的头结点 (虚拟头结点) */
LinkNode * head;
/* 链表的尾指针 */
LinkNode * tail;
};
(2)链表初始化
/* 链表初始化 */ int LinkListInit(LinkList **pList) { /* 初始化 */ LinkList *list = NULL; do { list = (LinkList *)malloc(sizeof(LinkList) * 1); if (list == NULL) { perror("malloc error"); break; } /* 清除脏数据 */ memset(list, 0, sizeof(LinkList) * 1); list->head = createLinkNode(0);//链表中需要创建结点 if (list->head == NULL) { perror("malloc error"); break; } /* 初始化的时候, 将尾指针指向头 */ list->tail = list->head; /* 初始化链表结点个数为0. */ list->size = 0; /* 解引用 */ *pList = list; return ON_SUCCESS; } while(0); /* 释放堆空间 */ if (list != NULL && list->head != NULL) { free(list->head); list->head = NULL; } if (list != NULL) { free(list); list = NULL; } return MALLOC_ERROR; }
(3)创建一个链表结点
/* 创建一个链表结点 */ static LinkNode *createLinkNode(ELEMENTTYPE data) { LinkNode * newNode = (LinkNode *)malloc(sizeof(LinkNode) * 1); if (newNode == NULL) { perror("malloc error"); return NULL; } /* 清除脏数据 */ memset(newNode, 0, sizeof(LinkNode) * 1); newNode->data = data; newNode->next = NULL; return newNode; }
(4)指定位置插入
-
虚拟头结点没有任何数据,他只有指针域,没有数据域
-
头插和尾插只需要调用该接口就行,位置分别为0和size。
/* 链表任意位置插 */
int LinkListAppointPosInsert(LinkList *pList, int pos, ELEMENTTYPE data)
{
/* 判空 */
if (pList == NULL)
{
return NULL_PTR;
}
/* 判断位置合法性 todo... */
if (pos < 0 || pos > pList->size)
{
return INVALID_ACCESS;
}
/* 把数据封装成结点 */
LinkNode * newNode = createLinkNode(data);
if (newNode == NULL)
{
perror("malloc error");
return MALLOC_ERROR;
}
/* head 是虚拟头结点 */
LinkNode * travelNode = pList->head;
int flag = 0;
if (pos == pList->size)
{
travelNode = pList->tail;
/* 需要修改尾指针的标记 */
flag = 1;
}
else
{
while (pos)
{
travelNode = travelNode->next;
pos--;
}
}
/* 挂结点 */
newNode->next = travelNode->next; // 1
travelNode->next = newNode; // 2
if (flag == 1)
{
/* 移动尾指针 */
pList->tail = newNode;
}
/* 链表的元素个数加一 */
(pList->size)++;
return ON_SUCCESS;
}
(5)链表遍历
/* 链表的遍历 */ int LinkListForeach(LinkList *pList, int (*printFunc)(ELEMENTTYPE)) { /* 判空 */ if (pList == NULL) { return NULL_PTR; } LinkNode * travelNode = pList->head->next; while (travelNode != NULL) { #if 0 printf("val: %d\n", *(int *)(travelNode->data)); #else printFunc(travelNode->data); #endif /* 查找下一个结点 */ travelNode = travelNode->next; } return ON_SUCCESS; }
(6)链表的长度
/* 获取链表的长度 */ int LinkListGetSize(LinkList *pList, int *pSize) { if (pList == NULL) { return NULL_PTR; } if (pSize) { *pSize = pList->size; } return ON_SUCCESS; }
(7)指定位置删除
/* 链表删除任意位置 */
int LinkListAppointPosDelete(LinkList *pList, int pos)
{
/* 判空 */
if (pList == NULL)
{
return NULL_PTR;
}
/* 判断位置合法性 */
if (pos < 0 || pos > pList->size - 1)
{
return INVALID_ACCESS;
}
LinkNode * travelNode = pList->head;
int flag = 0;
if (pos == pList->size - 1)
{
/* 需要移动尾指针的标记 */
flag = 1;
}
while (pos)
{
travelNode = travelNode->next;
pos--;
}
/* 退出循环的条件: travelNode是我要删除结点的前一个结点 */
LinkNode * delNode = travelNode->next;
travelNode->next = delNode->next;
if (flag == 1)
{
pList->tail = travelNode;
}
/* 释放堆空间 */
if (delNode != NULL)
{
free(delNode);
delNode = NULL;
}
/* 链表的元素个数减一 */
pList->size--;
return ON_SUCCESS;
}
(8)删除指定的值
/* 根据值获得链表的位置 */ static int LinkListAccordAppointDataGetPos(LinkList *pList, ELEMENTTYPE data, int *pos, int (*compareFunc)(ELEMENTTYPE arg1, ELEMENTTYPE arg2)) { LinkNode * travelNode = pList->head->next; int position = 0; while (travelNode != NULL) { int cmp = compareFunc(data, travelNode->data); if (cmp == 0) { *pos = position; return position; } travelNode = travelNode->next; position++; } *pos = -1; return -1; }
/* 链表删除任意的值 */ int LinkListAppointDataDelete(LinkList *pList, ELEMENTTYPE data, int (*compareFunc)(ELEMENTTYPE, ELEMENTTYPE)) { if(pList == NULL) { return NULL_PTR; } int pos = -1; while (LinkListAccordAppointDataGetPos(pList, data, &pos, compareFunc) != -1) { LinkListAppointPosDelete(pList, pos); } return ON_SUCCESS; }
(9)在链表末尾位置插入元素
尾指针的引入,目的是减少时间消耗,优化性能,直接把尾指针放在末尾,避免了指针从头的遍历至末尾
(10)链表头部插入元素
(11)主函数
-
代码中, LinkListInit()里面参数是二级指针,所以必须定义一个一级指针,然后再传进一级指针的地址。
-
这里只给出定义
int main() { LinkList *list = NULL; LinkListInit(&list); LinkListForeach(list, printData); printf("\n"); return 0; }
(12)其他接口
int printData(void *arg) { int val = *(int *)arg; printf("val:%d\t", val); } typedef struct StuInfo { int age; char name[BUFFER_SIZE]; } StuInfo; int printStruct(void *arg) { int ret = 0; StuInfo * stu = (StuInfo *)arg; printf("age:%d,\t name:%s\n", stu->age, stu->name); return ret; } int compareFunc(void *arg1, void *arg2) { int val1 = *(int *)arg1; int val2 = *(int *)arg2; return val1 - val2; }
(13)链表虚拟头结点
使用了虚拟节点(dummyNode
),它与只用真实链表头节点的写法相比,有几个显著的区别:
-
简化边界条件:
-
虚拟头节点:可以简化处理头部插入或删除的操作,因为虚拟头节点使得链表头部成为一种常规的节点,避免了单独处理头节点的特殊情况。
-
真实头节点:需要特别处理链表头部的插入、删除等操作,尤其是在处理链表的首节点时可能需要额外的逻辑。
-
-
代码一致性:
-
虚拟头节点:允许将所有节点处理逻辑统一化,使代码更简洁和一致。例如,在翻转链表或分组操作中,无论处理链表的哪一部分,操作方式都是一致的。
-
真实头节点:在处理链表的边界(如链表头部)时,通常需要分别考虑边界条件,这可能导致代码复杂度增加。
-
-
避免空指针错误:
-
虚拟头节点:提供了一个始终有效的起始节点,减少了因头节点为空或链表操作导致的空指针错误。
-
真实头节点:在某些情况下(如链表为空),需要特别处理可能出现的空指针问题。
-
-
示例对比
使用虚拟头节点的情况:
ListNode *dummyNode = new ListNode(-1); dummyNode->next = head; return dummyNode->next;
-
使用虚拟头节点可以在处理头部时简化逻辑,例如在翻转链表时,无需特别处理头节点的边界情况。
不使用虚拟头节点的情况:
ListNode *prev = nullptr; ListNode *cur = head; ListNode *next = nullptr; if (cur != nullptr && cur->next != nullptr) { // 处理头节点的特殊情况 } return prev;
-
不使用虚拟头节点时,处理头节点时可能需要额外的逻辑,特别是在插入或删除操作中需要考虑链表的空情况或特殊位置。
(14)反转k组一个链表
-
力扣25题
1.使用虚拟头节点
ListNode* reverseKGroup(ListNode* head, int k) { // 创建虚拟头节点,并将其 `next` 指向链表的头节点 ListNode dummy; dummy.next = head; ListNode* prevGroupEnd = &dummy; // 记录前一组翻转的尾节点 // 计算链表的总节点数 ListNode* cur = head; int count = 0; while (cur != nullptr) { count++; cur = cur->next; } // 翻转链表中的每一组 k 个节点 while (count >= k) { ListNode* groupStart = prevGroupEnd->next; // 当前组的开始节点 ListNode* prev = nullptr; // 前一个节点,初始为 nullptr ListNode* next = nullptr; // 用于暂存当前节点的下一个节点 cur = groupStart; // 翻转当前组的 k 个节点 for (int i = 0; i < k; ++i) { next = cur->next; // 保存下一个节点 cur->next = prev; // 当前节点指向前一个节点,完成翻转 prev = cur; // 更新前一个节点为当前节点 cur = next; // 当前节点向后移动 } // 完成当前组的翻转,连接前一组的尾节点 prevGroupEnd->next->next = cur; // 当前组的头节点变成尾节点,指向后续部分 prevGroupEnd->next = prev; // 前一组的尾节点指向翻转后的当前组的头节点 prevGroupEnd = prevGroupEnd->next; // 更新前一组尾节点为当前组的尾节点 count -= k; // 减去已经处理的节点数 } // 返回虚拟头节点的 next 指向的节点作为新的头节点 return dummy.next; }
2.不使用虚拟头结点
ListNode* reverseKGroup(ListNode* head, int k) { // 计算链表中节点的总数 ListNode *cur = head; int count = 0; while (cur != nullptr) { count++; // 统计节点总数 cur = cur->next; } // 如果链表节点总数小于 k,直接返回原链表 if (count < k) { return head; } // 逆转前 k 个节点的链表 ListNode *prev = nullptr; // 前一个节点,初始为 nullptr ListNode *next = nullptr; // 用于暂存当前节点的下一个节点 cur = head; // 翻转 k 个节点 for (int i = 0; i < k; ++i) { next = cur->next; // 保存下一个节点 cur->next = prev; // 当前节点指向前一个节点,完成翻转 prev = cur; // 更新前一个节点为当前节点 cur = next; // 当前节点向后移动 } // 递归处理剩余链表中的节点 // 如果剩余链表存在节点(即 cur 不为空),继续递归处理 if (cur != nullptr) { head->next = reverseKGroup(cur, k); // 将翻转后的链表连接到当前部分的尾部 } // 返回翻转后的新头节点 return prev; } };
4、双向链表
双向链表(Doubly Linked List)是一种链表数据结构的变体,每个节点包含两个指针,一个指向前一个节点(prev
),一个指向后一个节点(next
)。这种结构使得在链表中进行双向遍历成为可能,既可以从头到尾遍历,也可以从尾到头遍历。
-
双向链表的基本结构
-
每个节点通常包含三个部分:
-
数据域(Data):存储节点的数据。
-
前驱指针(Prev):指向链表中的前一个节点。
-
后继指针(Next):指向链表中的下一个节点。
-
-
-
双向链表的操作
-
创建节点:
-
在创建节点时,需要初始化
prev
和next
指针。
-
-
插入节点:
-
在头部插入:将新节点的
next
指针指向原头节点,原头节点的prev
指针指向新节点,然后更新头指针为新节点。 -
在尾部插入:将新节点的
prev
指针指向原尾节点,原尾节点的next
指针指向新节点,然后更新尾指针为新节点。 -
在中间插入:调整前驱节点和后继节点的
next
和prev
指针,以插入新节点。
-
-
删除节点:
-
删除头部节点:将头指针更新为下一个节点,并将新的头节点的
prev
指针设置为NULL
。 -
删除尾部节点:将尾指针更新为前一个节点,并将新的尾节点的
next
指针设置为NULL
。 -
删除中间节点:调整前驱节点和后继节点的
next
和prev
指针,以删除目标节点。
-
-
遍历:
-
正向遍历:从头节点开始,依次访问每个节点的
next
指针。 -
反向遍历:从尾节点开始,依次访问每个节点的
prev
指针。
-
-
-
双向链表优点
-
双向遍历:可以从任意节点向前或向后遍历链表。这使得在某些操作中,如双向查找或修改节点,变得更加高效和灵活。
-
更高效的删除操作:删除一个节点时,无论是从头部、中间还是尾部,只需要调整相邻节点的指针。相比于单向链表中的删除操作(需要从头部遍历到目标节点),双向链表的删除操作效率更高,因为可以直接访问前一个节点。
-
方便的插入操作:在双向链表中,插入节点操作相对简单,因为可以直接修改相邻节点的指针,而不需要遍历链表。
-
容易实现某些算法:双向链表使得实现一些需要双向访问的数据结构和算法变得更加简单,如LRU缓存(最近最少使用缓存)等。
-
-
双向链表缺点
-
更高的内存消耗:每个节点除了存储数据外,还需要额外的空间来存储两个指针(
prev
和next
)。这导致双向链表比单向链表占用更多的内存。 -
增加了实现复杂度:在双向链表中,节点的插入、删除和移动操作需要同时更新两个指针(
prev
和next
),这增加了代码的复杂性。 -
维护指针的复杂性:在进行节点插入和删除操作时,必须非常小心地维护两个指针,以避免出现指针悬挂或链表断裂的问题。
-
(1)双向链表的定义
struct DoubleLinkNode { /* 数据域 */ ELEMENTTYPE data; /* 指针域 */ struct DoubleLinkNode *prev; /* prev指针,指向前结点 */ struct DoubleLinkNode *next; /* next指针 */ }; /* 创建一个链表结点 */ static DoubleLinkNode *createDoubleLinkNode(ELEMENTTYPE data) { DoubleLinkNode * newNode = (DoubleLinkNode *)malloc(sizeof(DoubleLinkNode) * 1); if (newNode == NULL) { perror("malloc error"); return NULL; } /* 清除脏数据 */ memset(newNode, 0, sizeof(DoubleLinkNode) * 1); newNode->data = data; newNode->prev = NULL; newNode->next = NULL; return newNode; }
(2)在指定位置插入(包括头插和尾插)
-
当双向链表是空链表以及双向链表头插都满足如中右上角的条件
尾插时不满足其中一个条件,如图中的第3个条件。
/* 链表任意位置插 */
int DoubleLinkListAppointPosInsert(DoubleLinkList *pList, int pos, ELEMENTTYPE data)
{
/* 判空 */
if (pList == NULL)
{
return NULL_PTR;
}
/* 判断位置合法性 todo... */
if (pos < 0 || pos > pList->size)
{
return INVALID_ACCESS;
}
/* 把数据封装成结点 */
DoubleLinkNode * newNode = createDoubleLinkNode(data);
if (newNode == NULL)
{
perror("malloc error");
return MALLOC_ERROR;
}
/* head 是虚拟头结点 */
DoubleLinkNode * travelNode = pList->head;
int flag = 0;
if (pos == pList->size)
{
travelNode = pList->tail;
/* 需要修改尾指针的标记 */
flag = 1;
}
else
{
while (pos)
{
travelNode = travelNode->next;
pos--;
}
}
/* 挂结点 */
newNode->next = travelNode->next; // 1
newNode->prev = travelNode; // 2
if (flag == 0)
{
travelNode->next->prev = newNode; // 3
}
travelNode->next = newNode; // 4
if (flag == 1)
{
/* 移动尾指针 */
pList->tail = newNode;
}
/* 链表的元素个数加一 */
(pList->size)++;
return ON_SUCCESS;
}
(3)在指定位置删除
-
头删与尾删也适用
/* 链表删除任意位置 */
int DoubleLinkListAppointPosDelete(DoubleLinkList *pList, int pos)
{
/* 判空 */
if (pList == NULL)
{
return NULL_PTR;
}
/* 判断位置合法性 */
if (pos < 0 || pos > pList->size - 1)
{
return INVALID_ACCESS;
}
DoubleLinkNode * travelNode = pList->head;
DoubleLinkNode * delNode = NULL;
int flag = 0;
if (pos == pList->size - 1)
{
/* 需要移动尾指针的标记 */
flag = 1;
/* 尾指针指向的结点 是要删除的结点 */
delNode = pList->tail;
/* 尾指针移动 */
travelNode = delNode->prev;
/* 手动置为NULL */
travelNode->next = NULL;
}
else
{
while (pos)
{
travelNode = travelNode->next;
pos--;
}
/* 退出循环的条件: travelNode是我要删除结点的前一个结点 */
delNode = travelNode->next;
travelNode->next = delNode->next;
delNode->next->prev = travelNode;
}
if (flag == 1)
{
pList->tail = travelNode;
}
/* 释放堆空间 */
if (delNode != NULL)
{
free(delNode);
delNode = NULL;
}
/* 链表的元素个数减一 */
pList->size--;
return ON_SUCCESS;
}
(4)遍历与反向遍历
-
因为具有双向,因此可以进行逆序遍历
/* 链表的遍历 */ int DoubleLinkListForeach(DoubleLinkList *pList, int (*printFunc)(ELEMENTTYPE)) { /* 判空 */ if (pList == NULL) { return NULL_PTR; } DoubleLinkNode * travelNode = pList->head->next; while (travelNode != NULL) { #if 0 printf("val: %d\n", *(int *)(travelNode->data)); #else printFunc(travelNode->data); #endif /* 查找下一个结点 */ travelNode = travelNode->next; } return ON_SUCCESS; }
/* 链表的反向遍历 */ int DoubleLinkListReverseForeach(DoubleLinkList *pList, int (*printFunc)(ELEMENTTYPE)) { /* 判空 */ if (pList == NULL) { return NULL_PTR; } DoubleLinkNode * travelNode = pList->tail; while (travelNode != pList->head) { printFunc(travelNode->data); travelNode = travelNode->prev; } /* 退出条件是: 碰到虚拟头结点 */ return ON_SUCCESS; }
(5)删除指定的值
/* 根据值 获得链表的位置 */ static int DoubleLinkListAccordAppointDataGetPos(DoubleLinkList *pList, ELEMENTTYPE data, int *pos, int (*compareFunc)(ELEMENTTYPE arg1, ELEMENTTYPE arg2)) { DoubleLinkNode * travelNode = pList->head->next; int position = 0; while (travelNode != NULL) { int cmp = compareFunc(data, travelNode->data); if (cmp == 0) { *pos = position; return position; } travelNode = travelNode->next; position++; } *pos = -1; return -1; } /* 链表删除任意的值 */ int DoubleLinkListAppointDataDelete(DoubleLinkList *pList, ELEMENTTYPE data, int (*compareFunc)(ELEMENTTYPE, ELEMENTTYPE)) { if(pList == NULL) { return NULL_PTR; } int pos = -1; while (DoubleLinkListAccordAppointDataGetPos(pList, data, &pos, compareFunc) != -1) { DoubleLinkListAppointPosDelete(pList, pos); } return ON_SUCCESS; }
(6)获取链表的元素
/* 获取链表任意位置的元素 */ int DoubleLinkListGetAppointPositionData(DoubleLinkList *pList, int pos, ELEMENTTYPE *data) { /* 判空 */ if (pList == NULL) { return NULL_PTR; } /* 判断位置的合法性 */ if (pos < 0 || pos > pList->size - 1) { return INVALID_ACCESS; } DoubleLinkNode * travelNode = pList->head->next; /* 取最后一个元素 */ if (pos == pList->size - 1) { travelNode = pList->tail; } else { while (pos) { travelNode = travelNode->next; pos--; } /* 出了这个循环, travelNode到底是啥? */ /* travelNode 就是我要找的结点 */ } if (data) { *data = travelNode->data; } return ON_SUCCESS; }
5、队列
(1)调用双向链表的接口
-
与双向链表有相同的地方,直接调用双向链表的接口
#ifndef __COMMON_H__ #define __COMMON_H__ #define ELEMENTTYPE void * typedef struct DoubleLinkNode DoubleLinkNode; typedef struct DoubleLinkList DoubleLinkList; #endif //__COMMON_H__
-
doubleLinkListQueue.c
#include "doubleLinkListQueue.h" /* 队列初始化 */ int doubleLinkListQueueInit(DoubleLinkListQueue **queue) { return DoubleLinkListInit(queue); } /* 队列入队 */ int doubleLinkListQueuePush(DoubleLinkListQueue *queue, ELEMENTTYPE data) { return DoubleLinkListTailInsert(queue, data); } /* 队列出队 */ int doubleLinkListQueuePop(DoubleLinkListQueue *queue) { return DoubleLinkListHeadDelete(queue); } /* 队列的队头元素 */ int doubleLinkListQueueFront(DoubleLinkListQueue *queue, ELEMENTTYPE *data) { return DoubleLinkListGetHeadPositionData(queue, data); } /* 队列的队尾元素 */ int doubleLinkListQueueRear(DoubleLinkListQueue *queue, ELEMENTTYPE *data) { return DoubleLinkListGetTailPositionData(queue, data); } /* 队列的元素个数 */ int doubleLinkListQueueGetSize(DoubleLinkListQueue *queue, int *pSize) { return DoubleLinkListGetSize(queue, pSize); } /* 队列是否为空 */ int doubleLinkListQueueIsEmpty(DoubleLinkListQueue *queue) { int size = 0; DoubleLinkListGetSize(queue, &size); return size == 0 ? 1 : 0; } /* 队列销毁 */ int doubleLinkListQueueDestroy(DoubleLinkListQueue *queue) { return DoubleLinkListDestroy(queue); }
(2)主函数
#include <stdio.h> #include "doubleLinkListQueue.h" #define BUFFER_SIZE 5 int main() { /* 队列初始化 */ DoubleLinkListQueue *queue = NULL; doubleLinkListQueueInit(&queue); int nums[BUFFER_SIZE] = {11, 22, 33, 44, 55}; /* 入队 */ for (int idx = 0; idx < BUFFER_SIZE; idx++) { doubleLinkListQueuePush(queue, (void *)&nums[idx]); } int size = 0; doubleLinkListQueueGetSize(queue, &size); printf("size = %d\n", size); /* 队列非空 */ int *frontValue = NULL; while (!doubleLinkListQueueIsEmpty(queue)) { doubleLinkListQueueFront(queue, (void *)&frontValue); /* 出队 */ doubleLinkListQueuePop(queue); printf("frontValue:%d\n", *frontValue); } /* 释放队列 */ doubleLinkListQueueDestroy(queue); return 0; }
6、区别总结
1. 数组(Array)
数组是一个线性数据结构,存储在连续的内存位置中。每个元素通过一个索引来访问,索引通常从0开始。数组的特点是访问速度快(通过索引可以在O(1)时间内访问),但插入和删除元素的效率较低(需要移动元素,时间复杂度为O(n))。
优点:
-
访问元素快。
-
易于实现和使用。
缺点:
-
大小固定,难以动态扩展。
-
插入和删除操作复杂。
2. 链表(Linked List)
链表是由一组节点组成的数据结构,每个节点包含数据和指向下一个节点的指针。链表中的节点不需要存储在连续的内存位置,因此大小可以动态扩展。
优点:
-
动态大小,易于插入和删除元素(时间复杂度为O(1))。
-
不需要预先分配内存。
缺点:
-
访问速度慢(需要从头遍历,时间复杂度为O(n))。
-
额外的指针存储会消耗更多内存。
3. 队列(Queue)
队列是一种先进先出(FIFO,First-In-First-Out)的线性数据结构,元素在一端插入(称为队尾),在另一端移除(称为队头)。队列的典型应用场景包括任务调度、打印队列等。
优点:
-
遵循FIFO原则,适合处理顺序相关的任务。
-
插入和删除操作效率高(时间复杂度为O(1))。
缺点:
-
随机访问元素的效率低(时间复杂度为O(n))。
4. 栈(Stack)
栈是一种后进先出(LIFO,Last-In-First-Out)的线性数据结构,元素在一端插入和移除(称为栈顶)。栈的典型应用场景包括表达式求值、递归调用等。
优点:
-
遵循LIFO原则,适合处理逆序操作。
-
插入和删除操作效率高(时间复杂度为O(1))。
缺点:
-
随机访问元素的效率低(时间复杂度为O(n))。
5. 二叉树(Binary Tree)
二叉树是一种树形数据结构,每个节点最多有两个子节点,称为左子节点和右子节点。二叉树广泛应用于搜索、排序等领域。
优点:
-
结构灵活,可以表示多种数据关系(例如二叉搜索树、堆等)。
-
具备递归性质,适合递归算法。
缺点:
-
复杂性较高,操作较为繁琐。
-
不平衡的二叉树可能退化为线性结构,导致性能下降。
这些数据结构各自具有不同的应用场景,理解其优缺点对于选择合适的数据结构解决实际问题非常重要。
7、二叉堆(binaryHeap)
(1)基本概念
二叉堆是一种特殊的完全二叉树,用于实现优先队列。它是一种用于高效地支持最小值(最小堆)或最大值(最大堆)操作的数据结构。二叉堆有很多应用,尤其在实现优先队列、堆排序等算法中非常重要。
1.基本概念
二叉堆具有以下两个主要特性:
-
堆性质(Heap Property):
-
最小堆:对于二叉堆中的每个节点,其值都小于或等于其子节点的值。这样,根节点包含整个堆中的最小值。
-
最大堆:对于二叉堆中的每个节点,其值都大于或等于其子节点的值。这样,根节点包含整个堆中的最大值。
-
-
完全二叉树:
-
二叉堆是完全二叉树,即除了最后一层之外,每一层的节点都是满的,且最后一层的节点都集中在左侧。
-
2. 二叉堆的实现
二叉堆通常使用数组来实现。对于一个节点 i
,其左子节点、右子节点和父节点的索引可以通过以下公式计算:
-
左子节点:
2 * i + 1
-
右子节点:
2 * i + 2
-
父节点:
(i - 1) / 2
(对于i
大于0的情况下)
3. 二叉堆的操作
以下是二叉堆常见的操作:
a. 插入操作
-
将一个新元素插入到二叉堆中通常需要以下步骤:
-
将元素添加到堆的末尾:即数组的最后一个位置。
-
上滤(heapify-up 或 bubble-up):将插入的元素与其父节点比较,如果不符合堆的性质(最小堆或最大堆),则交换元素,并继续向上比较直到堆性质恢复为止。
-
-
时间复杂度:插入操作的时间复杂度为 O(log n),其中 n 是堆中元素的个数。
b. 删除最小/最大元素(堆顶元素)
-
删除堆顶元素的步骤如下:
-
将堆顶元素(最小值或最大值)与堆的最后一个元素交换。
-
删除最后一个元素,并将原堆顶元素移到堆的末尾。
-
下滤(heapify-down 或 sift-down):从堆顶开始将其与子节点比较,调整位置以恢复堆性质,直到堆性质恢复为止。
-
-
时间复杂度:删除操作的时间复杂度为 O(log n)。
c. 获取堆顶元素
-
对于最小堆:堆顶元素是最小值。
-
对于最大堆:堆顶元素是最大值。
-
时间复杂度:获取堆顶元素的时间复杂度为 O(1)。
d. 构建堆
-
从一个无序的数组构建一个二叉堆可以使用“建堆”算法。该算法的步骤如下:
-
从数组的最后一个非叶子节点开始,依次对每个节点执行下滤操作,直到根节点。
-
这样做的时间复杂度为 O(n),其中 n 是数组的大小。
-
4. 二叉堆的优缺点
优点:
-
高效的插入和删除操作:相对于其他数据结构(如平衡二叉搜索树),二叉堆在插入和删除操作上表现良好。
-
简单的实现:使用数组表示,使得实现相对简单。
缺点:
-
不支持快速的随机访问:与数组不同,二叉堆不支持快速的随机访问。
-
堆序列的访问较复杂:虽然可以高效地访问最小值或最大值,但堆的其他元素访问比较复杂,且堆中元素的顺序不能保证。
5. 应用
-
优先队列:通过最小堆或最大堆来实现优先队列,支持插入、删除最小/最大元素等操作。
-
堆排序:利用二叉堆进行排序,将堆顶的最小或最大值与最后一个元素交换,调整堆,逐步得到有序数组。
-
图算法:如 Dijkstra 算法和 Prim 算法中都用到了优先队列,可以通过二叉堆来实现。
(2)堆的存储方式
-
其存储顺序采用二叉树的顺序存储
-
图中根结点的索引从0开始,不从1开始。注意是索引,不是里面的值。
-
本质上是优先级队列
(3)小顶堆与大顶堆
3.1 小顶堆
3.2 大顶堆
(4)堆的定义及初始化
#define ELEMENT_TYPE void * typedef struct BinaryHeap { ELEMENT_TYPE * data; /* 数据域 */ int size; /* 堆元素个数 */ int capacity; /* 容量 */ /* 回调函数:必须定义成函数指针 */ int (*compareFunc)(ELEMENT_TYPE arg1, ELEMENT_TYPE arg2); } BinaryHeap; /* 二叉堆的初始化 */ int binaryHeapInit(BinaryHeap * heap, int (*compareFunc)(ELEMENT_TYPE arg1, ELEMENT_TYPE arg2)) { if (heap == NULL) { return NULL_PTR; } heap->capacity = DEFALUT_CAPACITY; heap->data = (ELEMENT_TYPE *)malloc(sizeof(ELEMENT_TYPE) * heap->capacity); if (heap->data == NULL) { perror("malloc error"); exit(-1); } /* 清空脏数据 */ memset(heap->data, 0, sizeof(ELEMENT_TYPE) * heap->capacity); /* 初始化元素个数 */ heap->size = 0; /* 比较器 */ heap->compareFunc = compareFunc; return ON_SUCCESS; }
(5)堆的插入
/* 二叉堆的新增 */ int binaryHeapInsert(BinaryHeap * heap, ELEMENT_TYPE data) { if (heap == NULL) { return NULL_PTR; } /* 判断容量是否满 */ if (heap->size == heap->capacity) { expandBinaryHeapCapacity(heap); } heap->data[(heap->size)] = data; /* 是否满足堆的特性 */ /* 上浮 */ floatUp(heap, heap->size); /* 更新元素个数 */ (heap->size)++; return ON_SUCCESS; }
(6)上浮操作
-
小顶堆操作
-
将当前结点与其父结点进行比较,若父节点比当前结点大,则开始交换
/* 小顶堆 */
static int floatUp(BinaryHeap * heap, int index)
{
/* 当前结点的值 */
ELEMENT_TYPE curIndexVal = heap->data[index];
#if 0
int cmp = 0;
while (index > 0) //index=0是根结点
{
/* 父结点索引 */
int parentIndex = (index - 1) >> 1;
cmp = heap->compareFunc(heap->data[index], heap->data[parentIndex]);
if (cmp > 0)
{
break;
}
/* 交换元素的值 */
ELEMENT_TYPE tmpData = heap->data[index];
heap->data[index] = heap->data[parentIndex];
heap->data[parentIndex] = tmpData;
index = parentIndex;
}
#else
int cmp = 0;
while (index > 0)//此方法是为了减少两者交换,将当前值复制给copynum
{
/* 父结点索引 */
int parentIndex = (index - 1) >> 1;
cmp = heap->compareFunc(curIndexVal, heap->data[parentIndex]);
if (cmp > 0)
{
break;
}
/* 将父结点元素值 拷贝到 当前位置 */
heap->data[index] = heap->data[parentIndex];
index = parentIndex;
}
/* 最后赋值 */
heap->data[index] = curIndexVal;
#endif
return ON_SUCCESS;
}
(7)下沉操作
-
开始对左、右开始比较
-
左边比右边大,则根节点选择右边开始交换
-
循环,直到索引index > size/2
/* 下沉 */
static int sinkDown(BinaryHeap * heap, int index)
{
ELEMENT_TYPE currentData = heap->data[index];
int cmp = 0;
/* 第一个叶子结点的索引 = 非叶子结点的数量 */
/* 必须保证index位置是非叶子结点 */
int halfIndex = heap->size >> 1;
while (index < halfIndex)
{
/* index的结点 有两种情况 */
/* 1. 有两个子结点 */
/* 2. 有一个子结点: 一定是左结点 */
/* 默认为左子结点 */
int childIndex = (index << 1) + 1;
/* 右子结点 */
int rightIndex = childIndex + 1;
/* 选出左右子结点中 较小的值 */
if (rightIndex < heap->size && heap->compareFunc(heap->data[rightIndex], heap->data[childIndex]) < 0)
{
childIndex = rightIndex;
}
/* 比较 */
cmp = heap->compareFunc(currentData, heap->data[childIndex]);
if (cmp < 0)
{
break;
}
/* 将子结点的值存放到当前位置 */
heap->data[index] = heap->data[childIndex];
/* 更新结点index */
index = childIndex;
}
heap->data[index] = currentData;
return ON_SUCCESS;
}
(8)扩容操作
/* 静态函数: 只在本源文件中使用 */
static int expandBinaryHeapCapacity(BinaryHeap *pArray)
{
int ret = 0;
/* 1. 数据备份 */
ELEMENT_TYPE * tmpData = pArray->data;
/* 2. 需要扩大的容量 */
int needExpandCapacity = pArray->capacity + (pArray->capacity >> 1);//1.5倍容量
pArray->data = (ELEMENT_TYPE *)malloc(sizeof(ELEMENT_TYPE) * needExpandCapacity);
if (pArray->data == NULL)
{
return MALLOC_ERROR;
}
/* 清除脏数据 */
memset(pArray->data, 0, sizeof(ELEMENT_TYPE) * needExpandCapacity);
/* 3. 数据迁移 */
for (int idx = 0; idx < pArray->size; idx++)
{
pArray->data[idx] = tmpData[idx];
}
/* 4. 释放内存 */
if (tmpData != NULL)
{
free(tmpData);
tmpData = NULL;
}
/* 5. 更新数组的属性 */
pArray->capacity = needExpandCapacity;
return ret;
}
(9) 二叉堆的删除
-
删除就是从顶堆开始删
-
删除索引1的位置,让后尾部的索引19上去,最后索引19所对应的值开始执行下沉操作
-
更新size,size--
/* 二叉堆的删除 */
int binaryHeapDelete(BinaryHeap * heap)
{
if (heap == NULL)
{
return NULL_PTR;
}
/* 没有元素 */
if (heap->size == 0)
{
return INVALID_ACCESS;
}
/* 至少有一个元素 */
/* 覆盖 */
heap->data[0] = heap->data[--(heap->size)];//原代码heap->data[heap->size - 1];(heap->size)--;
/* 下沉 */
sinkDown(heap, 0);
return ON_SUCCESS;
}
(10)堆顶元素、元素个数以及销毁
/* 二叉堆 堆顶元素 */
int binaryHeapTop(BinaryHeap * heap, ELEMENT_TYPE *data)
{
if (heap == NULL)
{
return NULL_PTR;
}
if (data)
{
*data = heap->data[0];
}
return ON_SUCCESS;
}
/* 二叉堆元素个数 */
int binaryHeapGetSize(BinaryHeap * heap, int * pSize)
{
if (heap == NULL || pSize == NULL)
{
return NULL_PTR;
}
*pSize = heap->size;
return ON_SUCCESS;
}
/* 二叉堆是否为空 */
bool binaryHeapIsEmpty(BinaryHeap * heap)
{
return heap->size == 0;
}
/* 二叉堆的销毁 */
int binaryHeapDetroy(BinaryHeap * heap)
{
if (heap == NULL)
{
return NULL_PTR;
}
if (heap->data != NULL)
{
free(heap->data);
heap->data = NULL;
}
return ON_SUCCESS;
}
(11)主函数测试
#include <stdio.h>
#include <string.h>
#include "binaryHeap.h"
int compareFunc(void * arg1, void * arg2)
{
int val1 = *(int *)arg1;//解引用,取里面的值
int val2 = *(int *)arg2;
return val1 - val2;//反过来就是大顶堆
}
int main()
{
BinaryHeap heap;
binaryHeapInit(&heap, compareFunc);
int nums[6] = {23, 54, 7, 16, 3, 41};
for (int idx = 0; idx < 6; idx++)
{
binaryHeapInsert(&heap, &nums[idx]);
}
/* 元素个数 */
int size = 0;
binaryHeapGetSize(&heap, &size);
printf("size is %d\n", size);
int *topVal = NULL;
while (!binaryHeapIsEmpty(&heap))
{
/* 堆顶元素 */
binaryHeapTop(&heap, (void **)&topVal);
binaryHeapDelete(&heap);
printf("topVal:%d\n", *topVal);
}
/* 销毁 */
binaryHeapDetroy(&heap);
return 0;
}
8、哈希表
-
哈希函数:把复杂类型映射成整形。-----------下标
-
键值对-------------------->目录:键值
-
哈希表:散列表
-
哈希冲突:拉链法(拉一个链表法):一个数组带多个链表的结构
-
查找速度最快的数据结构,速度O(1)。如果出现哈希冲突的时候最差会到O(n)。
7.1 哈希表概述
哈希表(Hash Table)是一种数据结构,用于高效地存储和检索数据。它通过哈希函数将键(Key)映射到一个固定大小的数组中。哈希表能够在常数时间复杂度内进行插入、删除和查找操作(在理想情况下)。
7.2 关键组成部分
-
哈希函数:
-
定义:哈希函数是一个算法,它将输入键转换成数组中的索引。
-
作用:通过哈希函数将键映射到数组中的位置,从而快速存取数据。
-
示例:对于整数键
k
,哈希函数h(k)
可以定义为k % N
,其中N
是数组的大小。
-
-
数组(桶):
-
定义:哈希表内部维护一个数组,其中的每个元素称为“桶”或“槽”。
-
作用:用于存储通过哈希函数映射到该位置的数据。
-
-
冲突处理:
-
定义:当两个不同的键通过哈希函数映射到同一个索引时,就会发生冲突。
-
解决方法:
-
链式地址法(Chaining):每个桶维护一个链表,将冲突的元素存储在链表中。
-
开放地址法(Open Addressing):当发生冲突时,探测数组中的其他位置,直到找到空槽或合适位置存储数据。
-
-
7.3 哈希表的操作
-
插入:
-
步骤:
-
使用哈希函数计算键的索引。
-
将数据存储在计算出的索引位置。
-
如果发生冲突,则使用冲突处理方法将数据存储在相应的位置。
-
-
示例:如果要插入键值对
(key=10, value="A")
,假设哈希函数为h(k) = k % 5
,则计算10 % 5 = 0
,将(10, "A")
存储在索引0
的位置。如果索引0
已经有数据,则链式地址法会在该位置的链表中添加数据。
-
-
查找:
-
步骤:
-
使用哈希函数计算键的索引。
-
查找计算出的索引位置的桶。
-
如果使用链式地址法,遍历链表以找到键。
-
如果使用开放地址法,按照探测序列查找数据。
-
-
示例:查找键
10
,计算10 % 5 = 0
,在索引0
的位置查找对应的值。如果有链表,则遍历链表找到对应的数据。
-
-
删除:
-
步骤:
-
使用哈希函数计算键的索引。
-
查找计算出的索引位置的桶。
-
如果使用链式地址法,从链表中删除对应的键。
-
如果使用开放地址法,标记位置为空或已删除。
-
-
示例:删除键
10
,计算10 % 5 = 0
,在索引0
的位置查找并删除对应的数据。如果有链表,则从链表中删除对应节点。
-
7.4 哈希表的优缺点
-
优点:
-
快速操作:在理想情况下,插入、查找和删除操作的时间复杂度是 O(1)O(1)。
-
动态调整:许多实现允许动态调整大小,保持操作效率。
-
-
缺点:
-
内存使用:为了减少冲突,哈希表通常需要额外的内存。
-
冲突处理开销:冲突处理可能会影响性能,特别是当负载因子较高时。
-
哈希函数的设计:哈希函数的选择对性能有重要影响,设计不良的哈希函数可能导致大量冲突。
-
7.5 简单hash举例
-
便于直观理解
-
链式地址法
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define TABLE_SIZE 10 // 哈希表的大小
// 链表节点定义
typedef struct Node {
int key;
int value;
struct Node* next;
} Node;
// 哈希表定义
typedef struct HashTable {
Node* table[TABLE_SIZE];
} HashTable;
// 创建一个新的哈希表
HashTable* create_table() {
HashTable* ht = (HashTable*)malloc(sizeof(HashTable));
for (int i = 0; i < TABLE_SIZE; i++) {
ht->table[i] = NULL;
}
return ht;
}
// 哈希函数
int hash(int key) {
return key % TABLE_SIZE;
}
// 插入一个键值对
void insert(HashTable* ht, int key, int value) {
int index = hash(key);
Node* new_node = (Node*)malloc(sizeof(Node));
new_node->key = key;
new_node->value = value;
new_node->next = NULL;
// 如果该索引位置为空,则直接插入
if (ht->table[index] == NULL) {
ht->table[index] = new_node;
} else {
// 如果位置已经有链表,则将新节点添加到链表的开头
new_node->next = ht->table[index];//new_node指向之前冲突的值
ht->table[index] = new_node;//再更新新的值,形成小标->新值->旧值,形成链表
}
}
// 查找键对应的值
int find(HashTable* ht, int key) {
int index = hash(key);
Node* current = ht->table[index];
while (current != NULL) {
if (current->key == key) {
return current->value;
}
current = current->next;
}
// 如果键不存在,返回 -1
return -1;
}
// 删除一个键值对
void delete(HashTable* ht, int key) {
int index = hash(key);
Node* current = ht->table[index];
Node* prev = NULL;
// 找到要删除的节点
while (current != NULL && current->key != key) {
prev = current;
current = current->next;
}
// 如果找不到该键,则返回
if (current == NULL) {
return;
}
// 从链表中移除节点
if (prev == NULL) {
// 要删除的节点是链表的第一个节点
ht->table[index] = current->next;
} else {
prev->next = current->next;
}
free(current);
}
// 打印哈希表内容
void print_table(HashTable* ht) {
for (int i = 0; i < TABLE_SIZE; i++) {
Node* current = ht->table[i];
printf("Index %d: ", i);
while (current != NULL) {
printf("(%d, %d) -> ", current->key, current->value);
current = current->next;
}
printf("NULL\n");
}
}
// 释放哈希表内存
void free_table(HashTable* ht) {
for (int i = 0; i < TABLE_SIZE; i++) {
Node* current = ht->table[i];
while (current != NULL) {
Node* temp = current;
current = current->next;
free(temp);
}
}
free(ht);
}
int main() {
// 创建哈希表
HashTable* ht = create_table();
// 插入一些键值对
insert(ht, 1, 100);
insert(ht, 2, 200);
insert(ht, 12, 300); // 这个会冲突到索引 2
insert(ht, 22, 400); // 这个会冲突到索引 2
// 打印哈希表内容
print_table(ht);
// 查找值
printf("Value for key 2: %d\n", find(ht, 2)); // 输出: 200
printf("Value for key 12: %d\n", find(ht, 12)); // 输出: 300
printf("Value for key 22: %d\n", find(ht, 22)); // 输出: 400
printf("Value for key 3: %d\n", find(ht, 3)); // 输出: -1 (未找到)
// 删除一个键值对
delete(ht, 2);
printf("After deleting key 2:\n");
print_table(ht);
// 释放哈希表内存
free_table(ht);
return 0;
}
7.6 完整hash
#ifndef __HASH_TABLE_H_
#define __HASH_TABLE_H_
#include "common.h"
#define SLOT_CAPACITY 10
#define HASH_KEYTYPE int
#define HASH_VALUETYPE int
typedef struct hashNode
{
HASH_KEYTYPE real_key;
HASH_VALUETYPE value;
} hashNode;
typedef struct hashTable
{
/* 哈希表的槽位数 */
int slotNums;
/* 哈希表的槽位号 (分配一块连续的存储空间) 指针数组(链表头结点) */
DoubleLinkList ** slotKeyId;
/* 自定义比较器 用于适配链表数据结构 */
int (*compareFunc)(ELEMENTTYPE, ELEMENTTYPE);
} HashTable;
/* 哈希表的初始化 */
int hashTableInit(HashTable** pHashtable, int slotNums, int (*compareFunc)(ELEMENTTYPE, ELEMENTTYPE));
/* 哈希表 插入<key, value> */
int hashTableInsert(HashTable *pHashtable, HASH_KEYTYPE key, HASH_VALUETYPE value);
/* 哈希表 删除指定key. */
int hashTableDelAppointKey(HashTable *pHashtable, HASH_KEYTYPE key);
/* 哈希表 根据key获取value. */
int hashTableGetAppointKeyValue(HashTable *pHashtable, HASH_KEYTYPE key, HASH_VALUETYPE *mapValue);
/* 哈希表元素大小 */
int hashTableGetSize(HashTable *pHashtable);
/* 哈希表的销毁 */
int hashTableDestroy(HashTable *pHashtable);
#endif //__HASH_TABLE_H_
#include <stdio.h>
#include "hashtable.h"
#include <stdlib.h>
#include "doubleLinkList.h"
#include <error.h>
#include <string.h>
#define DEFAULT_SLOT_NUMS 10
/* 函数前置声明 */
static int calHashValue(HashTable *pHashtable, HASH_KEYTYPE key, int *slotKeyId);
static hashNode * createHashNode(HASH_KEYTYPE key, HASH_VALUETYPE value);
/* 哈希表的初始化 */
int hashTableInit(HashTable** pHashtable, int slotNums, int (*compareFunc)(ELEMENTTYPE, ELEMENTTYPE))
{
/* 判空 */
if (pHashtable == NULL)
{
return -1;
}
int ret = 0;
HashTable * hash = (HashTable *)malloc(sizeof(HashTable) * 1);
if (hash == NULL)
{
perror("malloc error");
return MALLOC_ERROR;
}
/* 清除脏数据 */
memset(hash, 0, sizeof(HashTable) * 1);
/* 判断槽位号的合法性 */
if (slotNums <= 0)
{
slotNums = DEFAULT_SLOT_NUMS;
}
hash->slotNums = slotNums;
/* 动态数组分配空间 */
hash->slotKeyId = (DoubleLinkList **)malloc(sizeof(DoubleLinkList *) * (hash->slotNums));
if (hash->slotKeyId == NULL)
{
perror("malloc error");
return MALLOC_ERROR;
}
/* 清除脏数据 */
memset(hash->slotKeyId, 0, sizeof(DoubleLinkList*) * (hash->slotNums));
/* 初始化 : 每一个槽位号内部维护一个链表. */
for (int idx = 0; idx < hash->slotNums; idx++)
{
/* 为哈希表的value初始化。哈希表的value是链表的虚拟头结点 */
DoubleLinkListInit(&(hash->slotKeyId[idx]));
}
/* 自定义比较函数 钩子🪝函数 */
hash->compareFunc = compareFunc;
/* 指针解引用 */
*pHashtable = hash;
return ret;
}
/* 计算外部传过来的key 转化为哈希表内部维护的slotKeyId. slotKeyIds是数组(动态数组)索引 */
static int calHashValue(HashTable *pHashtable, HASH_KEYTYPE key, int *slotKeyId)
{
int ret = 0;
if (slotKeyId)
{
*slotKeyId = key % (pHashtable->slotNums);
}
return ret;
}
/* 新建结点 */
static hashNode * createHashNode(HASH_KEYTYPE key, HASH_VALUETYPE value)
{
/* 封装结点 */
hashNode * newNode = (hashNode *)malloc(sizeof(hashNode) * 1);
if (newNode == NULL)
{
return NULL;
}
/* 清除脏数据 */
memset(newNode, 0, sizeof(hashNode) * 1);
newNode->real_key = key;
newNode->value = value;
/* 返回新结点 */
return newNode;
}
/* 哈希表 插入<key, value> */
int hashTableInsert(HashTable *pHashtable, HASH_KEYTYPE key, HASH_VALUETYPE value)
{
/* 判空 */
if (pHashtable == NULL)
{
return -1;
}
int ret = 0;
/* 将外部传过来的key 转化为我哈希表对应的slotId */
int KeyId = 0;
calHashValue(pHashtable, key, &KeyId);
/* 创建哈希node */
hashNode * newNode = createHashNode(key, value);
if (newNode == NULL)
{
perror("create hash node error");
return MALLOC_ERROR;
}
/* todo: 去重... */
/* 将哈希结点插入到链表中. */
DoubleLinkListTailInsert(pHashtable->slotKeyId[KeyId], newNode);
return ret;
}
/* 哈希表 删除指定key. */
int hashTableDelAppointKey(HashTable *pHashtable, HASH_KEYTYPE key)
{
/* 判空 */
if (pHashtable == NULL)
{
return -1;
}
int ret = 0;
/* 将外部传过来的key 转化为我哈希表对应的slotId */
int KeyId = 0;
calHashValue(pHashtable, key, &KeyId);
hashNode tmpNode;
memset(&tmpNode, 0, sizeof(hashNode));
tmpNode.real_key = key;
#if 1
/* todo... 删除哈希结点 */
DoubleLinkNode * resNode = DoubleLinkListAppointKeyValGetNode((pHashtable->slotKeyId[KeyId]), &tmpNode, pHashtable->compareFunc);
if (resNode == NULL)
{
return -1;
}
/* 备份哈希结点 */
hashNode * delHashNode = resNode->data;
#endif
DoubleLinkListDelAppointData(pHashtable->slotKeyId[KeyId], &tmpNode, pHashtable->compareFunc);
if (delHashNode)
{
free(delHashNode);
delHashNode = NULL;
}
return ret;
}
/* 哈希表 根据key获取value. */
int hashTableGetAppointKeyValue(HashTable *pHashtable, int key, int *mapValue)
{
int ret = 0;
/* 将外部传过来的key 转化为我哈希表对应的slotId */
int KeyId = 0;
calHashValue(pHashtable, key, &KeyId);
hashNode tmpNode;
tmpNode.real_key = key;
DoubleLinkNode * resNode = DoubleLinkListAppointKeyValGetNode((pHashtable->slotKeyId[KeyId]), &tmpNode, pHashtable->compareFunc);
if (resNode == NULL)
{
return -1;
}
hashNode * mapNode = (hashNode*)resNode->data;
if (mapValue)
{
*mapValue = mapNode->value;
}
return ret;
}
/* 哈希表元素大小 */
int hashTableGetSize(HashTable *pHashtable)
{
if (pHashtable == NULL)
{
return 0;
}
int size = 0;
for (int idx = 0; idx < pHashtable->slotNums; idx++)
{
size += pHashtable->slotKeyId[idx]->len;
}
/* 哈希表的元素个数. */
return size;
}
/* 哈希表的销毁 */
int hashTableDestroy(HashTable *pHashtable)
{
/* 自己分配的内存自己释放 */
if (pHashtable == NULL)
{
return 0;
}
/* 谁开辟空间, 谁释放空间. */
/* 1. 先释放哈希表的结点 */
for (int idx = 0; idx < pHashtable->slotNums; idx++)
{
DoubleLinkNode * travelLinkNode = pHashtable->slotKeyId[idx]->head->next;
while (travelLinkNode != NULL)
{
/* 释放哈希结点 */
free(travelLinkNode->data);
travelLinkNode->data = NULL;
/* 指针位置移动 */
travelLinkNode = travelLinkNode->next;
}
}
/* 2. 释放哈希表每个槽维的链表 */
for (int idx = 0; idx < pHashtable->slotNums; idx++)
{
DoubleLinkListDestroy(pHashtable->slotKeyId[idx]);
}
/* 3. 释放槽位 */
if (pHashtable->slotKeyId != NULL)
{
free(pHashtable->slotKeyId);
pHashtable->slotKeyId = NULL;
}
/* 4. 释放哈希表 */
if (pHashtable != NULL)
{
free(pHashtable);
pHashtable = NULL;
}
}
9、二叉树
(1)结点的创建:包含数据域和指针域,用于存储data。
数据域:结点中存储数据元素的部分。
指针域:结点中存储数据元素之间的链接信息即下一个结点地址的部分。
typedef struct BinarySearchNode
{
/* 数据域 */
ELEMENTTYPE data;
/* 指针域 */
struct BinarySearchNode *left;
struct BinarySearchNode *right;
#if 1
struct BinarySearchNode * parent;
#endif
} BinarySearchNode;
(2)树的构建
-
通过自定义比较器,判断树的元素结点在右边还是在左边。
typedef struct BinarySearchTree { /* 树的根结点 */ BinarySearchNode *root; /* 树的元素个数 */ int size; /* 树的高度 */ int height; /* 自定义比较器 */ int (*compareFunc)(ELEMENTTYPE arg1, ELEMENTTYPE arg2); /* 自定义打印器 */ int (*printFunc)(ELEMENTTYPE arg); } BinarySearchTree;
(3)树结点的创建
-
参数需要一个父节点和插入的节点数据
/* 创建二叉搜索树的结点 */ static BinarySearchNode * createBinarySearchTreeNode(ELEMENTTYPE data, BinarySearchNode * parent) { BinarySearchNode *newNode = (BinarySearchNode *)malloc(sizeof(BinarySearchNode) * 1); if (newNode == NULL) { return NULL; } newNode->data = data; newNode->left = NULL; newNode->right = NULL; newNode->parent = parent; return newNode; }
(4)树的插入
-
如果travelNode指向的根节点不为空,就可以通过比较器确定位置,位置确定好后,就需要存放值了,那么需要创建newNode,将值存在newNode中。一定要注意创建的结点就是为了存放数据的!!!
/* 树的插入 */ int binarySearchTreeInsert(BinarySearchTree *pTree, ELEMENTTYPE data) { /* 判空 */ if (pTree == NULL) { return NULL_PTR; } /* 判断是否为空树 */ #if 0 if (pTree->size == 0) { } #else if (pTree->root == NULL) { pTree->root = createBinarySearchTreeNode(data, NULL); if (pTree->root == NULL) { return MALLOC_ERROR; } /* 树的元素个数加一. */ (pTree->size)++; return ON_SUCCESS; } #endif /* 程序走到这个地方, 一定不是空树 */ BinarySearchNode * travelNode = pTree->root; BinarySearchNode *parentNode = NULL; int cmp = 0; while (travelNode != NULL) { parentNode = travelNode;//将根节点赋给父节点 /* 比较器 */ cmp = pTree->compareFunc(data, travelNode->data);//通过与父节点的比较,确定结点的位置 if (cmp < 0) { travelNode = travelNode->left; } else if (cmp == 0) { #if 1 return ON_SUCCESS; #else travelNode->data = data; #endif } else if (cmp > 0) { travelNode = travelNode->right; } } /* 程序执行到这里 travelNode一定为NULL. */ BinarySearchNode *newNode = createBinarySearchTreeNode(data, parentNode); if (newNode == NULL) { return MALLOC_ERROR; } if (cmp < 0) { parentNode->left = newNode; } else if (cmp > 0) { parentNode->right = newNode; } /* 树的元素个数加一. */ (pTree->size)++; return ON_SUCCESS; }
(5)树的遍历
1.树的层序遍历
-
通过调用队列接口实现遍历。
* 树的层序遍历 */
/* */
int binarySearchTreeLevelOrder(BinarySearchTree *pTree)
{
if (pTree == NULL)
{
return NULL_PTR;
}
#if 1
if (pTree->root == NULL)
{
return ON_SUCCESS;
}
#else
if (pTree->size == 0)
{
return ON_SUCCESS;
}
#endif
DoubleLinkListQueue *queue = NULL;
doubleLinkListQueueInit(&queue);
doubleLinkListQueuePush(queue, pTree->root);
BinarySearchNode * frontVal = NULL;
while(!doubleLinkListQueueIsEmpty(queue))
{
/* 取出队头元素 */
doubleLinkListQueueFront(queue, (void **)&frontVal);
/* 出队 */
doubleLinkListQueuePop(queue);
/* 打印器 */
pTree->printFunc(frontVal->data);
/* 左子树入队 */
if (frontVal->left != NULL)
{
doubleLinkListQueuePush(queue, frontVal->left);
}
/* 右子树入队 */
if (frontVal->right != NULL)
{
doubleLinkListQueuePush(queue, frontVal->right);
}
}
/* 释放队列 */
doubleLinkListQueueDestroy(queue);
return ON_SUCCESS;
}
2. 树的高度
-
根据层序遍历来做,只需要判断当前层的结点全部出队后,高度+1。
-
解引用:操作符为解引用操作符,它返回指针pHeight所指的对象的值(注意不是地址)。通过指针,找到对应的内存和内存中的数据。再通过解引用访问或修改指针指向的内存内容。
/* 树的高度 */
int binarySearchTreeGetHeight(BinarySearchTree *pTree, int *pHeight)
{
if (pTree == NULL || pHeight == NULL)
{
return NULL_PTR;
}
/* 空树 */
if (pTree->root == NULL)
{
/* 解引用 */
*pHeight = 0;
return ON_SUCCESS;
}
/* 程序到这个地方, 根结点不为NULL. 一定有结点. */
int height = 0;
DoubleLinkListQueue *queue = NULL;
doubleLinkListQueueInit(&queue);
doubleLinkListQueuePush(queue, pTree->root);
int levelSize = 1;
BinarySearchNode * frontVal = NULL;
while(!doubleLinkListQueueIsEmpty(queue))
{
/* 取出队头元素 */
doubleLinkListQueueFront(queue, (void **)&frontVal);
/* 出队 */
doubleLinkListQueuePop(queue);
/* 当前层的结点个数减一 */
levelSize--;
/* 左子树入队 */
if (frontVal->left != NULL)
{
doubleLinkListQueuePush(queue, frontVal->left);
}
/* 右子树入队 */
if (frontVal->right != NULL)
{
doubleLinkListQueuePush(queue, frontVal->right);
}
if (levelSize == 0)
{
/* 树的高度加一. */
height++;
/* 当前层的结点已经全部结束了 */
doubleLinkListQueueGetSize(queue, &levelSize);
}
}
/* 释放队列 */
doubleLinkListQueueDestroy(queue);
/* 解引用 */
*pHeight = height;
return ON_SUCCESS;
}
3.前序遍历
-
先判断根节点,有根节点的先入队,其次左子树,再次右子树。顺序:45->29->21->76->68->57->84。
-
定义静态函数:用static修饰的函数,限定在本源码文件中,不能被本源码文件以外的代码文件调用。而普通的函数,默认是extern的,也就是说它可以被其它代码文件调用。在函数的返回类型前加上关键字static,函数就被定义成为静态函数。普通 函数的定义和声明默认情况下是extern的,但静态函数只是在声明他的文件当中可见。
-
不能被其他文件所用。定义静态函数有以下好处:
<1> 其他文件中可以定义相同名字的函数,不会发生冲突。
<2> 静态函数不能被其他文件所用。
/* 前序遍历 */ static int binarySearchTreeInnerPreOrder(BinarySearchTree *pTree, BinarySearchNode * travelNode)//静态函数 { if (travelNode == NULL) { return ON_SUCCESS; } /* 根结点 */ pTree->printFunc(travelNode->data); /* 左子树 */ binarySearchTreeInnerPreOrder(pTree, travelNode->left); /* 右子树 */ binarySearchTreeInnerPreOrder(pTree, travelNode->right); return ON_SUCCESS; } * 树的前序遍历 */ /* 根结点, 左子树, 右子树 */ int binarySearchTreePreOrder(BinarySearchTree *pTree) { if (pTree == NULL) { return NULL_PTR; } return binarySearchTreeInnerPreOrder(pTree, pTree->root); }
4. 中序遍历
-
先判断左子树,有左子树的先入队,其次根节点,再次右子树。顺序:21->29->45->57->68->76->84,其特点是递增。
/* 树的中序遍历 */ static int binarySearchTreeInnerInOrder(BinarySearchTree *pTree, BinarySearchNode * travelNode) { if (travelNode == NULL) { return ON_SUCCESS; } /* 左子树 */ binarySearchTreeInnerInOrder(pTree, travelNode->left); /* 根结点 */ pTree->printFunc(travelNode->data); /* 右子树 */ binarySearchTreeInnerInOrder(pTree, travelNode->right); return ON_SUCCESS; } /* 树的中序遍历 */ /* 左子树, 根结点, 右子树 */ int binarySearchTreeInOrder(BinarySearchTree *pTree) { if (pTree == NULL) { return NULL_PTR; } return binarySearchTreeInnerInOrder(pTree, pTree->root); }
5. 后序遍厉
左子树, 右子树, 根结点
/* 树的后序遍历 */ static int binarySearchTreeInnerPostOrder(BinarySearchTree *pTree, BinarySearchNode * travelNode) { if (travelNode == NULL) { return ON_SUCCESS; } /* 左子树 */ binarySearchTreeInnerPostOrder(pTree, travelNode->left); /* 右子树 */ binarySearchTreeInnerPostOrder(pTree, travelNode->right); /* 根结点 */ pTree->printFunc(travelNode->data); return ON_SUCCESS; } /* 树的后序遍历 */ /* 左子树, 右子树, 根结点 */ int binarySearchTreePostOrder(BinarySearchTree *pTree) { if (pTree == NULL) { return NULL_PTR; } return binarySearchTreeInnerPostOrder(pTree, pTree->root); }
(6)前驱结点
-
当前结点中序遍历(有序的)的前一个结点
1.度为2的情况
-
node->left,这是必须存在的,剩下就判断右子树。
-
中序遍历:3->29->55->62->63->71->76->84->99
2.度为1或者0的情况
-
如果在父节点的右边,直接返回父节点node->parent;
-
如果结点在父节点的左边,则一直往上走node->parent->parent->parent.....,直到parent结点在上一个结点parent->parent的右边,则返回parent->parent。
-
图中10没有前驱结点,代码中一直放回parent,直到根节点79,直接返回null了。
-
此前驱结点代码结合上面3种情况进行了优化
3.总结
-
如果节点有左子树,则它的前驱节点是左子树中最右边的节点(即左子树中的最大值节点)。
-
如果节点没有左子树,则它的前驱节点是第一个比该节点小的祖先节点。也就是说,从当前节点往上走,直到遇到一个节点,它是其父节点的右子节点,这个父节点就是前驱节点。
/* 结点的前驱结点 */ /* 前驱结点是: 当前结点中序遍历(有序的)的前一个结点 */ static BinarySearchNode * BinarySearchTreeNodeGetPrecursor(BinarySearchNode *node) { BinarySearchNode * travelNode = NULL; if (node->left != NULL) { travelNode = node->left; while (travelNode->right != NULL) { /* node->left->right->right->...... */ travelNode = travelNode->right; } return travelNode; } /* 如果程序执行到这里, 说明: 左子树一定为空 */ /* 只能够往上面(parent->parent->parent)找 */ travelNode = node; while (travelNode->parent != NULL && travelNode == travelNode->parent->left) { travelNode = travelNode->parent; } /* 退出这个循环: case1.travelNode->parent == NULL case2.当前结点是父结点的右边 */ return travelNode->parent; }
(7)后继结点
-
当前结点中序遍历(有序的)的后一个结点
-
若有右子树:node->right->left->left->left->left.....
-
若没有右子树:一直往上面走,直到一个parent结->left点有left就结束。
/* 结点的后继结点 */
/* 后继结点是: 当前结点中序遍历(有序的)的后一个结点 */
static BinarySearchNode * BinarySearchTreeNodeGetSuccessor(BinarySearchNode *node)
{
BinarySearchNode * travelNode = NULL;
if (node->right != NULL)
{
travelNode = node->right;
while (travelNode->left != NULL)
{
travelNode = travelNode->left;
}
return travelNode;
}
/* 程序执行到这个地方, 说明: 右子树一定为空 */
/* 只能够往上面走(parent->parent->parent...)找 */
travelNode = node;
while (travelNode->parent != NULL && travelNode == travelNode->parent->right)
{
travelNode = travelNode->parent;
}
/* 退出循环条件: case1.travelNode->parent == NULL case 2. travelNode == node->parent->left */
return travelNode->parent;
}
1.总结
-
如果节点有右子树,则它的后继节点是右子树中最左边的节点(即右子树中的最小值节点)。
-
如果节点没有右子树,则它的后继节点是第一个比该节点大的祖先节点。也就是说,从当前节点往上走,直到遇到一个节点,它是其父节点的左子节点,这个父节点就是后继节点。
2.例子
考虑如下二叉搜索树:
markdownCopy code 20 / \ 10 30 / \ \ 5 15 40 / \ 12 50
-
前驱节点:
-
节点
15
的前驱节点是12
,因为12
是15
左子树中最大的节点。 -
节点
30
的前驱节点是20
,因为20
是30
第一个比它小的祖先节点。
-
-
后继节点:
-
节点
15
的后继节点是20
,因为20
是第一个比15
大的祖先节点。 -
节点
12
的后继节点是15
,因为15
是12
右子树中最小的节点。
-
-
前驱节点是中序遍历中的前一个节点,可以通过检查左子树或向上寻找父节点来找到。
-
后继节点是中序遍历中的后一个节点,可以通过检查右子树或向上寻找父节点来找到。
(8)树的销毁
-
通过上述遍历方式,获取每个结点,通过free()进行释放,之后将树也free()释放一下。
/* 树的销毁 */ int binarySearchTreeDestroy(BinarySearchTree *pTree) { /* 只需要遍历到所有的结点 & 释放 */ #if 1 if (pTree == NULL) { return NULL_PTR; } if (pTree->root == NULL) { return ON_SUCCESS; } DoubleLinkListQueue *queue = NULL; doubleLinkListQueueInit(&queue); /* 根结点入队 */ doubleLinkListQueuePush(queue, pTree->root); BinarySearchNode * frontVal = NULL; while(!doubleLinkListQueueIsEmpty(queue)) { /* 取出队头元素 */ doubleLinkListQueueFront(queue, (void **)&frontVal); /* 出队 */ doubleLinkListQueuePop(queue); /* 左子树入队 */ if (frontVal->left != NULL) { doubleLinkListQueuePush(queue, frontVal->left); } /* 右子树入队 */ if (frontVal->right != NULL) { doubleLinkListQueuePush(queue, frontVal->right); } /* 释放结点 */ if (frontVal != NULL) { free(frontVal); frontVal = NULL; } } /* 释放队列 */ doubleLinkListQueueDestroy(queue); /* 释放树 */ if (pTree != NULL) { free(pTree); pTree = NULL; } return ON_SUCCESS; #else /* 使用中序遍历的方式去释放结点信息 */ #endif }
(9)树的删除
-
分为三种情况,都是通过找前驱结点,然后将前驱结点的值赋值给当前需要删除的结点,最后将前驱结点置空并释放。
1. 总体情况
2. 为0的情况:
3. 度为1的情况:
/* 树是否存在指定元素 */
int binarySearchTreeIsContainVal(BinarySearchTree *pTree, ELEMENTTYPE data)
{
if (pTree == NULL)
{
return NULL_PTR;
}
return baseAppointValGetBSTreeNode(pTree, data) == NULL ? 0 : 1;
}
/*通过指定的值获取对应的结点*/
static BinarySearchNode * baseAppointValGetBSTreeNode(BinarySearchTree *pTree, ELEMENTTYPE data)
{
BinarySearchNode * travelNode = pTree->root;
int cmp = 0;
while (travelNode != NULL)
{
cmp = pTree->compareFunc(data, travelNode->data);
if (cmp < 0)
{
travelNode = travelNode->left;
}
else if (cmp == 0)
{
return travelNode;
}
else if (cmp > 0)
{
travelNode = travelNode->right;
}
}
/* 退出循环: travelNode == NULL */
return travelNode;
}
//删除指定结点
static int binarySearchTreeDeleteNode(BinarySearchTree *pTree, BinarySearchNode * delNode)
{
int ret = 0;
if (delNode == NULL)
{
return ret;
}
/* 度为2 */
if (BinarySearchTreeNodeHasTwoChildrens(delNode))
{
/* 获取当前结点的前驱结点 */
BinarySearchNode * preNode = BinarySearchTreeNodeGetPrecursor(delNode);
/* 前驱结点的值 赋值到 度为2的结点 */
delNode->data = preNode->data;
delNode = preNode;
}
/* 程序到这个地方,要删除的结点要么是度为1 要么是度为0. */
/* 度为1 */
BinarySearchNode * childNode = delNode->left != NULL ? delNode->left : delNode->right;
BinarySearchNode * freeNode = NULL;
if (childNode != NULL)
{
/* 度为1 */
childNode->parent = delNode->parent;
if (delNode->parent == NULL)
{
/* 度为1 且是根结点 */
pTree->root = childNode;
freeNode = delNode;
}
else
{
if (delNode == delNode->parent->left)
{
delNode->parent->left = childNode;
}
else if (delNode == delNode->parent->right)
{
delNode->parent->right = childNode;
}
freeNode = delNode;
}
}
else
{
if (delNode->parent == NULL)
{
/* 度为0 && 根结点 */
freeNode = delNode;
/* 根结点置为NULL. */
pTree->root = NULL;
}
else
{
/* 度为0 */
if (delNode == delNode->parent->left)
{
delNode->parent->left = NULL;
}
else if (delNode == delNode->parent->right)
{
delNode->parent->right = NULL;
}
freeNode = delNode;
}
}
/* 释放堆空间 */
if (freeNode != NULL)
{
free(freeNode);
freeNode = NULL;
}
/* 树的元素个数减一. */
(pTree->size)--;
return ret;
}
/* 树的删除 */
int binarySearchTreeDelete(BinarySearchTree *pTree, ELEMENTTYPE data)
{
if (pTree == NULL)
{
return NULL_PTR;
}
#if 0
BinarySearchNode * delNode = baseAppointValGetBSTreeNode(pTree, data);
binarySearchTreeDeleteNode(pTree, delNode);
#else
binarySearchTreeDeleteNode(pTree, baseAppointValGetBSTreeNode(pTree, data));
#endif
return ON_SUCCESS;
}
/* 树的元素个数 */
int binarySearchTreeGetSize(BinarySearchTree *pTree, int *pSize)
{
if (pTree == NULL || pSize == NULL)
{
return NULL_PTR;
}
*pSize = pTree->size;
return ON_SUCCESS;
}
(10)主函数
#include <stdio.h>
#include "binarySearchTree.h"
#define BUFFER_SIZE 6
/* 比较器 */
int comparFuncBasic(void *arg1, void *arg2)
{
int val1 = *(int *)arg1;
int val2 = *(int *)arg2;
return val1 - val2;
}
/* 打印器 */
int printFuncBasic(void *arg)
{
int ret = 0;
int val = *(int *)arg;
printf("val:%d\t", val);
return ret;
}
int main()
{
BinarySearchTree * tree = NULL;
binarySearchTreeInit(&tree, comparFuncBasic, printFuncBasic);
/* 17 6 23 48 5 11 */
int nums[BUFFER_SIZE] = {17, 6, 23, 48, 5, 11};
for (int idx = 0; idx < BUFFER_SIZE; idx++)
{
binarySearchTreeInsert(tree, (void *)&nums[idx]);//(void *)&nums[idx]就是data
}
int size = 0;
binarySearchTreeGetSize(tree, &size);
printf("size:%d\n", size);
int height = 0;
binarySearchTreeGetHeight(tree, &height);
printf("height:%d\n", height);
/* 层序遍历 */
binarySearchTreeLevelOrder(tree);
printf("\n");
}
(11)常见的数据逻辑结构
(12)树知识点
-
树的特性:插入树的元素一定是具有可比较性的。
-
假设插入一组数据:12 2 34 23 5 6 45 8。
(1)如果按照顺序表的排列,需要查一个数据,就需要从头开始遍历,直到查到所需要的数据,如果数据太大,更浪费资源。所以引入树。
(2)树的排列有规律的,先插入一个进去作为根结点,如12,接着插入 2,2比12小,插入左边,接着34,34比12大,就放在12的右边,以此类推。因此查找的时候就能节省时间。
-
树的定义:
树(tree)是由n(n≥0)个结点组成的有限集合T。n=0的树称为空树;对n>0的树,有: (1)仅有一个特殊的结点称为根(root)结点,根结点没有前驱结点;
(2)当n>1时,除根结点外其余的结点分为m(m>0)个互不相交的有限集合T1,T2,…,Tm,其中每个集合Ti本身又是一棵树,称之为根的子树( subtree)。
-
注:树的定义具有递归性,即“树中还有树”。仅有一个根结点的树是最小树。
-
(13)空与非空链表
1. 空链表
-
当创建一个单链表时使用的是一级指针
定义一个指针指向结点head,即创建了一个链表的头指针,
BalanceBinarySearchNode *head head->NULL;
-
当在空链表时的链表尾插操作中,需要更改了头指针head的指向,因此在函数中要使用到二级指针,这里前提是头指针。
-
2.非空链表
-
一段非空链表:head->node->node1->node2->NULL
-
若想插入尾插,直接将node2->newnode,因此需要更改的是node2结构体的指针域的存储内容,因此这时我们操作只需要node2结构体的地址,即一级指针。
-
链表中传入二级指针的原因是我们会遇到需要更改头指针head的指向的情况。如果我们仅是在不改变头指针head的指向的情况下对链表进行操作(如非空链表的尾删,尾插,对非首结点(FirstNode)的结点的插入/删除操作等),则不需要用到二级指针.
10、平衡二叉树(AVL树)
定义:也称为AVL树,是一种特殊的二叉树结构,其定义要求每个节点的左子树和右子树的高度差不超过1,即平衡因子为-1,0,1这三种情况,|平衡因子| ≤ 1就平衡。
作用:这种约束条件保证了平衡二叉树的高度相对较低,使得在最坏情况下的查找、插入和删除操作的时间复杂度为O(log n),其中n是树中节点的个数。需要注意的是,平衡二叉树并不要求所有节点的左子树和右子树的高度完全相等,只要它们的高度差不超过1即可。这样可以通过适当地调整树的结构来保持平衡。
1.平衡二叉树的实现
(1)结构(增加的接口)
依据二叉树的代码构架,衍生出平衡二叉树的结构,增加了以下接口:
-
获取AVL树的结点平衡因子
-
判断AVL树是否搜索树结点是否平衡
-
更新高度:如果结点平衡,就更新高度。
-
当前结点是父结点的左子树
-
当前结点是父结点的右子树
-
获取当前结点中较高的子结点
-
插入结点需要做出的调整
-
删除结点需要做出的调整
-
调整平衡
-
左旋
-
右旋
-
统一旋转操作:由于左旋和右旋存在相同的旋转操作,为避免冗余代码,另起接口。
-
删除结点接口
(2)理论逻辑
-
平衡判断:需要对判断的第一个不平衡结点进行旋转处理,来达到平衡的目的,然后以此类推,直到整个树达到平衡,再更新树的高度。
-
新增的结点一定通过比较后放在最底下,所以一定是叶子结点,新增的结点只会改变跟他有关系的结点平衡,以及往上衍生,node->parent->parent->parent.....都有可能不平衡。
更新高度:比较该结点的左子树和右子树结点个数,取出最长的子树并加1就是该结点的高度。树的高度初始化默认为height = 1,每增加一个叶子结点,高度+1。
/* 创建二叉搜索树的结点 */
static BalanceBinarySearchNode *createBalanceBinarySearchTreeNode(ELEMENTTYPE data, BalanceBinarySearchNode *parent)
{
BalanceBinarySearchNode *newNode = (BalanceBinarySearchNode *)malloc(sizeof(BalanceBinarySearchNode) * 1);
if (newNode == NULL)
{
return NULL;
}
newNode->data = data;
newNode->left = NULL;
newNode->right = NULL;
newNode->parent = parent;
/*树的高度初始化默认为1*/
newNode->height = 1;
return newNode;
}
-
旋转逻辑:定义三个结点,一个是当前结点grand,一个是parent结点(当前grand结点的最高子结点),一个是parent结点的当前最高子节点child结点。通过结点的更替完成旋转。
-
不同的旋转,三个结点的位置关系不一样。
/**右旋/ BalanceBinarySearchNode *parent = grand->left; BalanceBinarySearchNode *child = parent->right; /**左旋/ BalanceBinarySearchNode *parent = grand->right; BalanceBinarySearchNode *child = parent->left;
-
左旋结点的定义:其中,图画错了,child结点是200.
-
右旋结点的定义:
树的结点的删除:需要删除85,找到85的前驱结点80,再将85覆盖,然后判断平不平衡即可。
2.AVL树的代码接口实现
(1)获取AVL树的结点平衡因子
/*平衡二叉搜索树结点平衡因子*/ static int BalanceBinarySearchNodeFactor(BalanceBinarySearchNode *node) { /*左子树高度*/ int leftHeight = node->left == NULL ? 0 : node->left->height; /*右子树高度*/ int rightHeight = node->right == NULL ? 0 : node->right->height; return leftHeight - rightHeight; }
(2)判断AVL树是否搜索树结点是否平衡
/*判断平衡二叉搜索树结点是否平衡*/ static int BalancebinarySearchTreeNodeIsBalanced(BalanceBinarySearchNode *node) { return abs(BalanceBinarySearchNodeFactor(node)) <= 1; }
(3)更新高度
/*取两者之间最大的数*/ static int tmpMax(int val1, int val2) { return val1 - val2 >= 0 ? val1 : val2; } /*更新高度*/ static int BalancebinarySearchTreeNodeUpdateHeight(BalanceBinarySearchNode *node) { /*左子树高度*/ int leftHeight = node->left == NULL ? 0 : node->left->height; /*右子树高度*/ int rightHeight = node->right == NULL ? 0 : node->right->height; /*直接更新高度*/ node->height = 1 + tmpMax(leftHeight, rightHeight); return ON_SUCCESS; }
注:这里的高度是指结点的高度(两个结点的之间高度,或者三个结点之间的高度,平衡因子的那块需要用到),在二叉树中定义的高度为整个树的高度,两者概念不一样。
(4)当前结点是父结点的左子树
/*当前结点是父结点的左子树*/ static int BalancebinarySearchTreeNodeIsLeft(BalanceBinarySearchNode *node) { /*是根结点,这行代码直接在return里面实现*/ return (node->parent != NULL) && (node = node->parent->left); }
(5)当前结点是父结点的右子树
/*当前结点是父结点的右子树*/ static int BalancebinarySearchTreeNodeIsRight(BalanceBinarySearchNode *node) { /*是根结点*/ return (node->parent != NULL) && (node = node->parent->right); }
(6)获取当前结点中较高的子结点
-
此代码是判断RR LL RL LR的一个接口,目的是判断左右子树哪边高,若左子树高,则是L,右子树高是R。然后返回该子结点。
/*获取当前结点中较高的子结点*/ static BalanceBinarySearchNode *BalancebinarySearchTreeGetTallerNode(BalanceBinarySearchNode *node) { /*左子树高度*/ int leftHeight = node->left == NULL ? 0 : node->left->height; /*右子树高度*/ int rightHeight = node->right == NULL ? 0 : node->right->height; if (leftHeight > rightHeight) { return node->left; } else if (leftHeight < rightHeight) { return node->right; } else { // leftHeight == rightHeight if (BalancebinarySearchTreeNodeIsLeft(node)) { return node->left; } else { return node->right; } } }
(7)插入结点需要做出的调整
-
插入结点后,可能造成树的失衡,因此还需要判断,若平衡,就更新高度,若不平衡,就调用调整平衡函数进行平衡
/*插入完数据后需要做出调整*/ static int insertNodeAfter(BinarySearchTree *pTree, BalanceBinarySearchNode *node) { int ret = 0; while ((node = node->parent) != NULL) { /*node是什么, 父结点 祖父结点 祖先结点*/ /*程序执行到这个地方,说明不止一个结点*/ if (BalancebinarySearchTreeNodeIsBalanced(node)) { /*如果平衡,就更新高度*/ BalancebinarySearchTreeNodeUpdateHeight(node); } else { /*如果不平衡,且node是最低的不平衡结点*/ BalancebinarySearchTreeNodeAdjustBalance(pTree, node); /*直接break*/ break; } } }
(8)删除结点需要做出的调整
-
同理,删除结点后可能导致失衡,也需要调用平衡函数。
/*删除结点需要做出的调整*/ /*node是要删除的点*/ static int removeNodeAfter(BinarySearchTree *pTree, BalanceBinarySearchNode *node) { while ((node = node->parent) != NULL) { /*程序执行到这个地方,说明不止一个结点*/ if (BalancebinarySearchTreeNodeIsBalanced(node)) { /*如果该点平衡,更新高度*/ BalancebinarySearchTreeNodeUpdateHeight(node); } else { /*如果不平衡,且node是最低的不平衡结点*/ BalancebinarySearchTreeNodeAdjustBalance(pTree, node); } } }
(9)调整平衡
/*该点不平衡,且node最低点的不平衡结点,就调整平衡*/ static int BalancebinarySearchTreeNodeAdjustBalance(BinarySearchTree *pTree, BalanceBinarySearchNode *node) { /*这里需要判断出不平衡的类型:LL RR LR RL*/ BalanceBinarySearchNode *parent = BalancebinarySearchTreeGetTallerNode(node); BalanceBinarySearchNode *child = BalancebinarySearchTreeGetTallerNode(parent); if (BalancebinarySearchTreeNodeIsLeft(parent)) { /*L?*/ if (BalancebinarySearchTreeNodeIsLeft(parent)) { /*LL*/ BalancebinarySearchTreeNodeRotateRight(pTree, node); } else { /*LR: 先左旋再右旋*/ BalancebinarySearchTreeNodeRotateLeft(pTree, parent); BalancebinarySearchTreeNodeRotateRight(pTree, node); } } else { /*R?*/ if (BalancebinarySearchTreeNodeIsLeft(child)) { /*RL:先右旋再左旋*/ BalancebinarySearchTreeNodeRotateRight(pTree, parent); BalancebinarySearchTreeNodeRotateLeft(pTree, node); } else { /*RR*/ BalancebinarySearchTreeNodeRotateLeft(pTree, node); } } }
(10)左旋
/*左旋:RR*/ static int BalancebinarySearchTreeNodeRotateLeft(BinarySearchTree *pTree, BalanceBinarySearchNode *grand) { BalanceBinarySearchNode *parent = grand->right; BalanceBinarySearchNode *child = parent->left; grand->right = child; // 1 parent->left = grand; // 2 /*统一旋转操作*/ BalancebinarySearchTreeNodeAfterRotate(pTree, grand, parent, child); return ON_SUCCESS; }
(11)右旋
/*右旋:LL*/ static int BalancebinarySearchTreeNodeRotateRight(BinarySearchTree *pTree, BalanceBinarySearchNode *grand) { BalanceBinarySearchNode *parent = grand->left; BalanceBinarySearchNode *child = parent->right; grand->left = child; // 1 parent->right = grand; // 2 /*统一旋转操作*/ BalancebinarySearchTreeNodeAfterRotate(pTree, grand, parent, child); return ON_SUCCESS; }
(12)统一旋转操作
/*统一旋转操作*/ static int BalancebinarySearchTreeNodeAfterRotate(BinarySearchTree *pTree, BalanceBinarySearchNode *grand, BalanceBinarySearchNode *parent, BalanceBinarySearchNode *child) { parent->parent = grand->parent; // 3 if (BalancebinarySearchTreeNodeIsLeft(grand)) { grand->parent->left = parent; // 4 } else if (BalancebinarySearchTreeNodeIsRight(grand)) { grand->parent->right = parent; // 4 } else { /*根结点*/ pTree->root = parent; } grand->parent = parent; // 5 if (child != NULL) { child->parent = grand; // 6 } /*调整高度,从低往高*/ BalancebinarySearchTreeNodeUpdateHeight(grand); BalancebinarySearchTreeNodeUpdateHeight(parent); return ON_SUCCESS; }
(13)删除结点接口
/*删除结点接口*/ static int BalancebinarySearchTreeDeleteNode(BinarySearchTree *pTree, BalanceBinarySearchNode *delNode) { int ret = 0; if (delNode == NULL) { return ret; } /* 度为2 */ if (BalanceBinarySearchTreeNodeHasTwoChildrens(delNode)) { /* 获取当前结点的前驱结点 */ BalanceBinarySearchNode *preNode = BinarySearchTreeNodeGetPrecursor(delNode); /* 前驱结点的值 赋值到 度为2的结点 */ delNode->data = preNode->data; delNode = preNode; } /* 程序到这个地方,要删除的结点要么是度为1 要么是度为0. */ /* 度为1 */ BalanceBinarySearchNode *childNode = delNode->left != NULL ? delNode->left : delNode->right; BalanceBinarySearchNode *freeNode = NULL; if (childNode != NULL) { /* 度为1 */ childNode->parent = delNode->parent; if (delNode->parent == NULL) { /* 度为1 且是根结点 */ pTree->root = childNode; freeNode = delNode; /*删除的结点*/ removeNodeAfter(pTree, freeNode); } else { if (BalancebinarySearchTreeNodeIsLeft(delNode)) { delNode->parent->left = childNode; } else if (delNode == delNode->parent->right) { delNode->parent->right = childNode; } freeNode = delNode; /*删除的结点*/ removeNodeAfter(pTree, freeNode); } } else { if (delNode->parent == NULL) { /* 度为0 && 根结点 */ freeNode = delNode; /*删除的结点*/ removeNodeAfter(pTree, freeNode); /* 根结点置为NULL. */ pTree->root = NULL; } else { /* 度为0 */ if (BalancebinarySearchTreeNodeIsLeft(delNode)) { delNode->parent->left = NULL; } else if (BalancebinarySearchTreeNodeIsRight(delNode)) { delNode->parent->right = NULL; } freeNode = delNode; /*删除的结点*/ removeNodeAfter(pTree, freeNode); } } /* 释放堆空间 */ if (freeNode != NULL) { free(freeNode); freeNode = NULL; } /* 树的元素个数减一. */ (pTree->size)--; return ret; }
3.判断不平衡的类型
-
定义:node 是当前结点,parent是当前结点node的最高子结点,child是parent结点的最高子结点。注意这里的三个结点跟旋转接口里面定义的结点规则不一样。
-
LR型判断规则:先判断parent结点是否在左边,若是,这是L,进入L型,再次判断child是否在左边,若不是,则是LR型,则需要对parent先左旋,当前结点node 再右旋。
-
RL型判断规则:先判断parent结点是否在右边,若是,这是R,进入R型,再次判断child是否在左边,若是,则是RL型,则需要对parent先右旋,当前结点node 再左旋。
/*该点不平衡,且node最低点的不平衡结点,就调整平衡*/ static int BalancebinarySearchTreeNodeAdjustBalance(BinarySearchTree *pTree, BalanceBinarySearchNode *node) { /*这里需要判断出不平衡的类型:LL RR LR RL*/ BalanceBinarySearchNode *parent = BalancebinarySearchTreeGetTallerNode(node); BalanceBinarySearchNode *child = BalancebinarySearchTreeGetTallerNode(parent); if (BalancebinarySearchTreeNodeIsLeft(parent)) { /*L?*/ if (BalancebinarySearchTreeNodeIsLeft(child)) { /*LL*/ BalancebinarySearchTreeNodeRotateRight(pTree, node); } else { /*LR: 先左旋再右旋*/ BalancebinarySearchTreeNodeRotateLeft(pTree, parent); BalancebinarySearchTreeNodeRotateRight(pTree, node); } } else { /*R?*/ if (BalancebinarySearchTreeNodeIsLeft(child)) { /*RL:先右旋再左旋*/ BalancebinarySearchTreeNodeRotateRight(pTree, parent); BalancebinarySearchTreeNodeRotateLeft(pTree, node); } else { /*RR*/ BalancebinarySearchTreeNodeRotateLeft(pTree, node); } } }
4.树的删除
(1)树的删除的两种情况:
-
调整高度
-
判断平衡
(2)通过指定的值获取对应的结点
-
通过找到要删除的结点的前驱结点,通过前驱结点覆盖掉该结点,然后再判断平不平衡。
/*通过指定的值获取对应的结点*/ static BalanceBinarySearchNode *baseAppointValGetBSTreeNode(BinarySearchTree *pTree, ELEMENTTYPE data) { BalanceBinarySearchNode *travelNode = pTree->root; int cmp = 0; while (travelNode != NULL) { cmp = pTree->compareFunc(data, travelNode->data); if (cmp < 0) { travelNode = travelNode->left; } else if (cmp == 0) { return travelNode; } else if (cmp > 0) { travelNode = travelNode->right; } } /* 退出循环: travelNode == NULL */ return travelNode; } /* 树的删除*/ int BalancebinarySearchTreeDelete(BinarySearchTree *pTree, ELEMENTTYPE data) { if (pTree == NULL) { return NULL_PTR; } #if 0 BalanceBinarySearchNode * delNode = baseAppointValGetBSTreeNode(pTree, data); BalancebinarySearchTreeDeleteNode(pTree, delNode); #else BalancebinarySearchTreeDeleteNode(pTree, baseAppointValGetBSTreeNode(pTree, data)); #endif return ON_SUCCESS; }
5.主函数
#include <stdio.h> #include "balanceBinarySearchTree.h" #define BUFFER_SIZE 6 /* 比较器 */ int comparFuncBasic(void *arg1, void *arg2) { int val1 = *(int *)arg1; int val2 = *(int *)arg2; return val1 - val2; } /* 打印器 */ int printFuncBasic(void *arg) { int ret = 0; int val = *(int *)arg; printf("val:%d\t", val); return ret; } int main() { BinarySearchTree * tree = NULL; BalancebinarySearchTreeInit(&tree, comparFuncBasic, printFuncBasic); /* 17 6 23 48 5 11 */ int nums[BUFFER_SIZE] = {17, 6, 23, 48, 5, 11}; for (int idx = 0; idx < BUFFER_SIZE; idx++) { BalancebinarySearchTreeInsert(tree, (void *)&nums[idx]);//(void *)&nums[idx]就是data } int size = 0; BalancebinarySearchTreeGetSize(tree, &size); printf("size:%d\n", size); int height = 0; BalancebinarySearchTreeGetHeight(tree, &height); printf("height:%d\n", height); /* 层序遍历 */ BalancebinarySearchTreeLevelOrder(tree); printf("\n"); }
11、DFS
-
DFS,全称为 Depth-First Search,即 深度优先搜索,是一种用于遍历或搜索树或图数据结构的算法。与广度优先搜索(BFS)不同,DFS 优先深入每一个可能的分支路径,直到无法继续为止,然后回溯并探索其他路径。
(1)DFS 的基本思想
-
DFS 的基本思想是从一个起始节点开始,沿着一个路径不断深入,直到到达一个无法继续的节点(即叶子节点或死胡同),然后回溯到最近的分叉点,继续探索未访问的分支。这一过程会递归进行,直到所有节点都被访问为止。
(2)DFS 的实现
-
DFS 可以使用两种主要方法实现:
-
递归:利用函数调用栈来处理节点的访问。
-
显式栈:手动维护一个栈结构来模拟递归过程。
-
1. 递归实现 DFS
递归是实现 DFS 的一种直观方法,代码如下:
#include <stdio.h> #include <stdlib.h> #include <stdbool.h> #define MAX_NODES 100 typedef struct Node { int vertex; struct Node* next; } Node; typedef struct Graph { int numVertices; Node** adjLists; bool* visited; } Graph; Node* createNode(int vertex) { Node* newNode = malloc(sizeof(Node)); newNode->vertex = vertex; newNode->next = NULL; return newNode; } Graph* createGraph(int vertices) { Graph* graph = malloc(sizeof(Graph)); graph->numVertices = vertices; graph->adjLists = malloc(vertices * sizeof(Node*)); graph->visited = malloc(vertices * sizeof(bool)); for (int i = 0; i < vertices; i++) { graph->adjLists[i] = NULL; graph->visited[i] = false; } return graph; } void addEdge(Graph* graph, int src, int dest) { // 从 src 到 dest 添加边 Node* newNode = createNode(dest); newNode->next = graph->adjLists[src]; graph->adjLists[src] = newNode; // 如果是无向图,添加反向边 newNode = createNode(src); newNode->next = graph->adjLists[dest]; graph->adjLists[dest] = newNode; } void DFS(Graph* graph, int vertex) { graph->visited[vertex] = true; printf("Visited %d\n", vertex); Node* adjList = graph->adjLists[vertex]; Node* temp = adjList; while (temp != NULL) { int connectedVertex = temp->vertex; if (graph->visited[connectedVertex] == false) { DFS(graph, connectedVertex); } temp = temp->next; } } int main() { Graph* graph = createGraph(6); addEdge(graph, 0, 1); addEdge(graph, 0, 2); addEdge(graph, 1, 3); addEdge(graph, 1, 4); addEdge(graph, 2, 5); addEdge(graph, 4, 5); DFS(graph, 0); // 释放内存 for (int i = 0; i < graph->numVertices; i++) { Node* temp = graph->adjLists[i]; while (temp) { Node* toFree = temp; temp = temp->next; free(toFree); } } free(graph->adjLists); free(graph->visited); free(graph); return 0; }
2. 显式栈实现 DFS
如果不使用递归,也可以使用栈来实现 DFS:
#include <stdio.h> #include <stdlib.h> #include <stdbool.h> #define MAX_NODES 100 typedef struct Node { int vertex; struct Node* next; } Node; typedef struct Graph { int numVertices; Node** adjLists; bool* visited; } Graph; typedef struct Stack { int items[MAX_NODES]; int top; } Stack; Node* createNode(int vertex) { Node* newNode = malloc(sizeof(Node)); newNode->vertex = vertex; newNode->next = NULL; return newNode; } Graph* createGraph(int vertices) { Graph* graph = malloc(sizeof(Graph)); graph->numVertices = vertices; graph->adjLists = malloc(vertices * sizeof(Node*)); graph->visited = malloc(vertices * sizeof(bool)); for (int i = 0; i < vertices; i++) { graph->adjLists[i] = NULL; graph->visited[i] = false; } return graph; } void addEdge(Graph* graph, int src, int dest) { Node* newNode = createNode(dest); newNode->next = graph->adjLists[src]; graph->adjLists[src] = newNode; newNode = createNode(src); newNode->next = graph->adjLists[dest]; graph->adjLists[dest] = newNode; } Stack* createStack() { Stack* stack = malloc(sizeof(Stack)); stack->top = -1; return stack; } void push(Stack* stack, int value) { if (stack->top == MAX_NODES - 1) { printf("Stack overflow\n"); } else { stack->items[++stack->top] = value; } } int pop(Stack* stack) { if (stack->top == -1) { printf("Stack underflow\n"); return -1; } else { return stack->items[stack->top--]; } } bool isStackEmpty(Stack* stack) { return stack->top == -1; } void DFS(Graph* graph, int startVertex) { Stack* stack = createStack(); graph->visited[startVertex] = true; push(stack, startVertex); while (!isStackEmpty(stack)) { int vertex = pop(stack); printf("Visited %d\n", vertex); Node* temp = graph->adjLists[vertex]; while (temp) { int adjVertex = temp->vertex; if (!graph->visited[adjVertex]) { graph->visited[adjVertex] = true; push(stack, adjVertex); } temp = temp->next; } } // 释放栈的内存 free(stack); } int main() { Graph* graph = createGraph(6); addEdge(graph, 0, 1); addEdge(graph, 0, 2); addEdge(graph, 1, 3); addEdge(graph, 1, 4); addEdge(graph, 2, 5); addEdge(graph, 4, 5); DFS(graph, 0); // 释放内存 for (int i = 0; i < graph->numVertices; { Node* temp = graph->adjLists[i]; while (temp) { Node* toFree = temp; temp = temp->next; free(toFree); } } free(graph->adjLists); free(graph->visited); free(graph); return 0; }
(3)DFS 的应用
DFS 广泛应用于各种算法和问题中,例如:
-
路径查找:在迷宫、棋盘或网络中查找路径。
-
连通分量检测:用于检测图中的连通分量(即在无向图中所有节点都相互连接的部分)。
-
拓扑排序:在有向无环图(DAG)中确定节点的线性顺序。
-
检测环路:在图中检测是否存在循环。
-
解决谜题:如数独、八皇后问题等,利用 DFS 进行回溯。
(4)DFS 的时间和空间复杂度
-
时间复杂度:O(V + E),其中 V 是节点数,E 是边数。每个节点和边都可能被访问一次。
-
空间复杂度:O(V) ,主要用于存储递归栈或显式栈,以及访问标记。
12、BFS
-
广度优先搜索(BFS,Breadth-First Search)是一种用于遍历或搜索树或图数据结构的算法。与深度优先搜索(DFS)不同,BFS 是按层次逐层地访问节点,先访问与起始节点距离最近的节点,再访问更远的节点。
(1)BFS 的基本思想
-
BFS 从一个起始节点开始,将该节点标记为已访问,并将其放入队列中。然后反复执行以下步骤:
-
从队列的前端取出一个节点。
-
访问该节点的所有未访问的邻居,并将这些邻居节点依次放入队列中。
-
重复以上步骤,直到队列为空。
-
(2)BFS 的实现
-
在 C 语言中实现 BFS 时,通常使用队列来存储当前层次的节点。以下是 BFS 的实现代码。
#include <stdio.h> #include <stdlib.h> #include <stdbool.h> #define MAX_NODES 100 // 队列的最大节点数 // 节点结构体,用于表示邻接表中的单个节点 typedef struct Node { int vertex; // 节点编号 struct Node* next; // 指向下一个相邻节点的指针 } Node; // 图结构体,用于表示整个图 typedef struct Graph { int numVertices; // 图中的节点数量 Node** adjLists; // 邻接表,用于存储每个节点的相邻节点 bool* visited; // 访问标记数组,用于标记节点是否已被访问 } Graph; // 队列结构体,用于实现BFS中的队列操作 typedef struct Queue { int items[MAX_NODES]; // 队列数组,用于存储队列中的节点 int front; // 队列头部索引 int rear; // 队列尾部索引 } Queue; // 创建新节点 Node* createNode(int vertex) { Node* newNode = malloc(sizeof(Node)); // 为新节点分配内存 newNode->vertex = vertex; // 设置节点编号 newNode->next = NULL; // 初始化下一个节点指针为 NULL return newNode; // 返回新节点的指针 } // 创建新图 Graph* createGraph(int vertices) { Graph* graph = malloc(sizeof(Graph)); // 为图结构体分配内存 graph->numVertices = vertices; // 设置图中的节点数量 graph->adjLists = malloc(vertices * sizeof(Node*)); // 为邻接表分配内存 graph->visited = malloc(vertices * sizeof(bool)); // 为访问标记数组分配内存 // 初始化邻接表和访问标记数组 for (int i = 0; i < vertices; i++) { graph->adjLists[i] = NULL; // 初始化每个节点的邻接表为空 graph->visited[i] = false; // 初始化每个节点的访问标记为未访问 } return graph; // 返回新图的指针 } // 添加边到图中 void addEdge(Graph* graph, int src, int dest) { // 从 src 到 dest 添加边 Node* newNode = createNode(dest); // 创建新节点表示 dest newNode->next = graph->adjLists[src]; // 将新节点插入 src 的邻接表 graph->adjLists[src] = newNode; // 更新邻接表 // 如果是无向图,还需添加从 dest 到 src 的边 newNode = createNode(src); // 创建新节点表示 src newNode->next = graph->adjLists[dest]; // 将新节点插入 dest 的邻接表 graph->adjLists[dest] = newNode; // 更新邻接表 } // 创建新队列 Queue* createQueue() { Queue* queue = malloc(sizeof(Queue)); // 为队列结构体分配内存 queue->front = -1; // 初始化队列头部索引 queue->rear = -1; // 初始化队列尾部索引 return queue; // 返回新队列的指针 } // 检查队列是否为空 bool isQueueEmpty(Queue* queue) { return queue->rear == -1; // 如果队列尾部索引为 -1,则队列为空 } // 将元素加入队列 void enqueue(Queue* queue, int value) { if (queue->rear == MAX_NODES - 1) { printf("Queue is full\n"); // 如果队列满了,输出提示 return; } if (queue->front == -1) { queue->front = 0; // 如果队列是空的,设置头部索引为 0 } queue->items[++queue->rear] = value; // 将元素加入队列,并更新尾部索引 } // 从队列中移除元素 int dequeue(Queue* queue) { if (isQueueEmpty(queue)) { printf("Queue is empty\n"); // 如果队列为空,输出提示 return -1; } int item = queue->items[queue->front]; // 获取队列头部的元素 queue->front++; // 更新头部索引 if (queue->front > queue->rear) { // 如果头部超过尾部,队列已空 queue->front = queue->rear = -1; // 重置队列 } return item; // 返回移除的元素 } // 广度优先搜索算法 void BFS(Graph* graph, int startVertex) { Queue* queue = createQueue(); // 创建队列用于BFS graph->visited[startVertex] = true; // 标记起始节点为已访问 enqueue(queue, startVertex); // 将起始节点加入队列 while (!isQueueEmpty(queue)) { // 当队列不为空时,继续搜索 int currentVertex = dequeue(queue); // 从队列中取出节点 printf("Visited %d\n", currentVertex); // 输出访问的节点 Node* temp = graph->adjLists[currentVertex]; // 获取当前节点的邻接表 // 遍历邻接表中的所有邻居节点 while (temp) { int adjVertex = temp->vertex; // 获取邻居节点 // 如果邻居节点未被访问 if (!graph->visited[adjVertex]) { graph->visited[adjVertex] = true; // 标记邻居节点为已访问 enqueue(queue, adjVertex); // 将邻居节点加入队列 } temp = temp->next; // 移动到下一个邻居节点 } } // 释放队列的内存 free(queue); } int main() { Graph* graph = createGraph(6); // 创建一个包含6个节点的图 // 添加边到图中 addEdge(graph, 0, 1); addEdge(graph, 0, 2); addEdge(graph, 1, 3); addEdge(graph, 1, 4); addEdge(graph, 2, 5); addEdge(graph, 4, 5); BFS(graph, 0); // 从节点0开始进行BFS // 释放内存 for (int i = 0; i < graph->numVertices; i++) { Node* temp = graph->adjLists[i]; while (temp) { Node* toFree = temp; // 释放邻接表中的节点内存 temp = temp->next; free(toFree); } } free(graph->adjLists); // 释放邻接表的内存 free(graph->visited); // 释放访问标记数组的内存 free(graph); // 释放图结构体的内存 return 0; }
13、红黑树
红黑树是一种自平衡二叉查找树,在插入、删除和查找操作的最坏情况下都能保持O(log n)的时间复杂度。红黑树被广泛应用于许多计算机系统中,如Linux内核中的调度器、STL中的map
和set
等。
1.红黑树的特点
红黑树是一种特殊的二叉查找树(BST),它在每个节点上增加了一个存储位,用来表示节点的颜色,可以是红色或黑色。红黑树通过对树的颜色及结构进行调整,保证树的大致平衡,从而在最坏情况下,基本操作(插入、删除、查找)的时间复杂度都是O(log n)。
红黑树必须满足以下性质:
-
节点是红色或黑色:这条性质定义了节点的颜色属性。
-
根节点是黑色:红黑树的根节点必须是黑色的。这保证了红黑树的基础结构。
-
所有叶子节点(
NULL
或NIL
节点)都是黑色:红黑树中的每个叶子节点都是黑色,并且这些叶子节点是NULL
或NIL
节点,即不存储数据。 -
红色节点的两个子节点必须都是黑色的(即不能有连续的红色节点):这条性质确保了没有两个连续的红色节点在树的路径上出现。
-
从任一节点到其每个叶子的所有路径都包含相同数目的黑色节点:这条性质保证了树的平衡,使得树的最长路径不会超过最短路径的两倍。
2.红黑树的操作
a. 插入操作
当插入一个新节点时,初始时它被标记为红色(避免违反性质5)。然后,可能会出现一些性质被破坏,需要进行调整。调整操作通常包括:
-
变色:改变节点的颜色,可能由红色变为黑色,或者由黑色变为红色。
-
左旋或右旋:通过旋转来调整树的结构,恢复红黑树的性质。
插入后的调整步骤包括:
-
如果新节点的父节点是黑色,不需要任何调整,红黑树的性质依然保持。
-
如果新节点的父节点是红色,则违反了性质4,这时候需要通过变色、旋转等操作恢复红黑树的性质。
b. 删除操作
删除节点比插入要复杂得多,因为删除一个节点可能会破坏红黑树的多条性质。删除后的调整步骤通常包括:
-
替代节点变色:如果删除节点是黑色,会引入额外的黑色,破坏性质5。通过变色来平衡树。
-
双重黑色:通过将某个子节点标记为“额外的黑色”,并通过旋转和变色来调整树的结构。
调整完成后,红黑树的所有性质将重新被维护。
3.红黑树的旋转操作
旋转操作是红黑树最核心的操作之一,分为左旋和右旋。旋转用于调整树的结构,以维持平衡。
a. 左旋
左旋的目的是让树的右子树上升为当前节点的父节点,当前节点成为其左子树的一部分。
// 左旋示意图 // 旋转前: // x // \ // y // / // z // // 旋转后: // y // / // x // \ // z
b. 右旋
右旋的目的是让树的左子树上升为当前节点的父节点,当前节点成为其右子树的一部分。
cppCopy code// 右旋示意图 // 旋转前: // y // / // x // \ // z // // 旋转后: // x // \ // y // \ // z
旋转操作会改变树的结构,但不会改变树中各节点的顺序关系。
4.红黑树的优势
-
平衡性:红黑树是一种自平衡树,保证了最坏情况下操作的时间复杂度为O(log n)。
-
插入、删除效率高:相比其他自平衡二叉树(如AVL树),红黑树的插入和删除操作效率更高。
-
广泛应用:红黑树由于其高效和稳定的性能,被广泛应用于标准库中的关联容器(如C++ STL中的
map
和set
)。
5.简单的代码示例
下面是一个简化的红黑树实现。这个实现包括插入操作,并维护红黑树的平衡性。为了简洁起见,删除操作未包括在内。
#include <stdio.h> #include <stdlib.h> // 定义节点颜色 typedef enum { RED, BLACK } Color; // 定义红黑树节点结构 typedef struct RBTreeNode { int data; // 数据域 Color color; // 节点颜色 struct RBTreeNode* left; // 左子节点 struct RBTreeNode* right; // 右子节点 struct RBTreeNode* parent; // 父节点 } RBTreeNode; // 定义红黑树结构 typedef struct { RBTreeNode* root; // 根节点 RBTreeNode* nil; // 哨兵节点,表示空节点(所有叶子节点) } RBTree; // 创建一个新的红黑树节点 RBTreeNode* createNode(int data, Color color, RBTreeNode* nil) { RBTreeNode* node = (RBTreeNode*)malloc(sizeof(RBTreeNode)); node->data = data; node->color = color; node->left = nil; node->right = nil; node->parent = nil; return node; } // 创建一个空的红黑树 RBTree* createTree() { RBTree* tree = (RBTree*)malloc(sizeof(RBTree)); // 初始化哨兵节点,所有空节点用这个节点表示 tree->nil = (RBTreeNode*)malloc(sizeof(RBTreeNode)); tree->nil->color = BLACK; tree->root = tree->nil; return tree; } // 左旋操作 void leftRotate(RBTree* tree, RBTreeNode* x) { RBTreeNode* y = x->right; // y 是 x 的右子节点 x->right = y->left; // 将 y 的左子树转为 x 的右子树 if (y->left != tree->nil) { y->left->parent = x; } y->parent = x->parent; // y 的父节点设置为 x 的父节点 if (x->parent == tree->nil) { tree->root = y; // 如果 x 是根节点,则 y 变为根节点 } else if (x == x->parent->left) { x->parent->left = y; // 如果 x 是左子节点,则 y 变为左子节点 } else { x->parent->right = y; // 如果 x 是右子节点,则 y 变为右子节点 } y->left = x; // x 变为 y 的左子节点 x->parent = y; } // 右旋操作 void rightRotate(RBTree* tree, RBTreeNode* y) { RBTreeNode* x = y->left; // x 是 y 的左子节点 y->left = x->right; // 将 x 的右子树转为 y 的左子树 if (x->right != tree->nil) { x->right->parent = y; } x->parent = y->parent; // x 的父节点设置为 y 的父节点 if (y->parent == tree->nil) { tree->root = x; // 如果 y 是根节点,则 x 变为根节点 } else if (y == y->parent->right) { y->parent->right = x; // 如果 y 是右子节点,则 x 变为右子节点 } else { y->parent->left = x; // 如果 y 是左子节点,则 x 变为左子节点 } x->right = y; // y 变为 x 的右子节点 y->parent = x; } // 修复红黑树以保持性质 void insertFixup(RBTree* tree, RBTreeNode* z) { while (z->parent->color == RED) { if (z->parent == z->parent->parent->left) { RBTreeNode* y = z->parent->parent->right; // y 是 z 的叔叔节点 if (y->color == RED) { // 情况1:叔叔是红色 z->parent->color = BLACK; // 父节点变黑 y->color = BLACK; // 叔叔变黑 z->parent->parent->color = RED; // 祖父节点变红 z = z->parent->parent; // z 上升两层 } else { if (z == z->parent->right) { // 情况2:z 是右子节点 z = z->parent; leftRotate(tree, z); // 左旋 } z->parent->color = BLACK; // 情况3:z 是左子节点 z->parent->parent->color = RED; rightRotate(tree, z->parent->parent); // 右旋 } } else { RBTreeNode* y = z->parent->parent->left; // y 是 z 的叔叔节点 if (y->color == RED) { // 情况1:叔叔是红色 z->parent->color = BLACK; y->color = BLACK; z->parent->parent->color = RED; z = z->parent->parent; } else { if (z == z->parent->left) { // 情况2:z 是左子节点 z = z->parent; rightRotate(tree, z); // 右旋 } z->parent->color = BLACK; // 情况3:z 是右子节点 z->parent->parent->color = RED; leftRotate(tree, z->parent->parent); // 左旋 } } } tree->root->color = BLACK; // 根节点始终为黑色 } // 插入新节点到红黑树中 void insert(RBTree* tree, int data) { RBTreeNode* z = createNode(data, RED, tree->nil); // 新节点初始为红色 RBTreeNode* y = tree->nil; RBTreeNode* x = tree->root; while (x != tree->nil) { // 找到插入位置 y = x; if (z->data < x->data) { x = x->left; } else { x = x->right; } } z->parent = y; if (y == tree->nil) { tree->root = z; // 如果树是空的,新节点为根节点 } else if (z->data < y->data) { y->left = z; // 新节点作为左子节点插入 } else { y->right = z; // 新节点作为右子节点插入 } insertFixup(tree, z); // 修复红黑树的性质 } // 中序遍历红黑树 void inorderTraversal(RBTree* tree, RBTreeNode* node) { if (node != tree->nil) { inorderTraversal(tree, node->left); printf("%d (%s) ", node->data, node->color == RED ? "R" : "B"); inorderTraversal(tree, node->right); } } // 销毁红黑树并释放内存 void destroyTree(RBTree* tree, RBTreeNode* node) { if (node != tree->nil) { destroyTree(tree, node->left); destroyTree(tree, node->right); free(node); } } int main() { RBTree* tree = createTree(); // 插入节点 insert(tree, 10); insert(tree, 20); insert(tree, 30); insert(tree, 15); insert(tree, 25); // 中序遍历并打印红黑树 inorderTraversal(tree, tree->root); printf("\n"); // 销毁红黑树 destroyTree(tree, tree->root); free(tree->nil); free(tree); return 0; }
-
代码解析
-
节点颜色:
RED
表示红色,BLACK
表示黑色。每个节点有一个颜色属性,用于维持红黑树的平衡。
-
-
哨兵节点:
nil
节点用于表示所有叶子节点,它们统一为黑色。这简化了红黑树的实现,使得插入和旋转操作不必处理NULL
指针。
-
旋转操作:包括
leftRotate
和rightRotate
,用于在树结构需要调整时改变节点的位置。
-
插入操作:
insert
函数插入新节点到红黑树中,并调用insertFixup
函数修复可能被破坏的红黑树性质。
-
遍历和销毁:
inorderTraversal
进行中序遍历,destroyTree
递归销毁树并释放内存。
文件IO
1、系统调用和库函数的区别
系统调用(System Call)和库函数(Library Function)是操作系统和编程语言中常见的概念,但它们有不同的作用和层次。下面是它们的主要区别:
1. 定义
-
系统调用:系统调用是操作系统提供的接口,用于让用户程序直接与操作系统内核进行交互。它允许程序执行特权操作,如文件操作、进程控制、内存管理等。系统调用通常需要从用户模式切换到内核模式,以执行这些操作。
-
库函数:库函数是编程语言提供的预定义函数,封装了常见的功能,如字符串处理、数学运算、文件操作等。库函数通常由标准库提供,运行在用户模式下,不需要直接与操作系统内核交互。
-
开发过程中需要注意资源和效率问题
2. 执行层次
-
系统调用:执行时需要进入内核模式,这意味着它涉及从用户模式到内核模式的切换,这个过程比较耗时。
-
库函数:通常在用户模式下执行,不需要进入内核模式,因此执行速度比系统调用快。
3. 功能和作用
-
系统调用:提供底层操作系统服务,例如文件读写、进程管理、网络通信等。这些操作往往是必须通过系统调用才能完成的,因为它们涉及操作系统的核心资源。
-
库函数:提供对常见任务的高层次封装,例如字符串操作、数学计算等。库函数可能内部使用系统调用,但它们对程序员隐藏了底层的复杂性。
4. 使用方式
-
系统调用:一般直接通过操作系统的API调用,需要较多的参数和特权操作。例如在Linux中,使用
write()
系统调用来写文件。 -
库函数:通过编程语言的标准库调用,更易于使用。例如,C语言中的
printf()
函数是一个库函数,用于输出数据。
5. 性能
-
系统调用:由于涉及用户模式和内核模式的切换,开销较大,因此性能可能不如库函数。
-
库函数:通常运行在用户模式下,开销较小,执行速度更快。
6. 安全性
-
系统调用:由于直接与操作系统内核交互,系统调用通常需要更多的安全检查,以防止程序对系统资源的误用或恶意利用。
-
库函数:一般没有直接接触操作系统内核,因此相对更安全,但某些库函数可能会间接调用系统调用。
2、文件属性
(1)文件属性解读
(2)添加和删除权限
-
chmod
-
使用chmod实现添加和删除操作,比如chmod -x就是删除文件的可执行操作。
3、文件描述符
-
文件描述符的0、1、2分别表示标准输入流、标准输出流和标准错误流。具体来说:
-
0代表标准输入流:通常对应着键盘的设备文件,可以理解为键盘输入。在编程中,可以使用这个描述符来读取从键盘输入的数据。
-
1代表标准输出流:通常对应着显示器的设备文件,用于将程序运行的结果输出到屏幕上。通过这个描述符,程序可以将需要展示给用户的信息发送到终端。
-
2代表标准错误流:也是输出到终端,但它用于输出错误信息。当程序运行出错时,错误信息会通过这个描述符发送到终端,以便用户能够及时发现并处理错误。
-
-
在Linux系统中,每个进程都会默认打开这三个文件描述符。这些描述符在编程中非常重要,因为它们提供了进程与外部环境(如键盘、显示器等)进行交互的接口。通过操作这些描述符,程序可以实现数据的输入和输出功能。
4、文件IO
(1)open函数
-
函数定义
-
flag:打开文件的行为标记
-
文件权限解读
-
rwx = 111 = 1×2^2 + 1×2^1 + 1×2^0 = 7 --------->因此对应的r->4;w->2, x->1。
-
因此属性:0644 = rw--r--r--
-
test.txt文件添加0644属性之后的属性如下
文件打开个数
代码段
#include <stdio.h>
#include <fcntl.h>
#include <errno.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#define BUFFER_SIZE 32
int main()
{
const char *name = "./test.txt";
int fd1 = open(name, O_RDWR | O_CREAT | O_APPEND, 0644);//O_APPEND:追加,不写这个那么之前的的数据就会被覆盖
if (fd1 == -1)
{
perror("open error");
exit(-1);
}
printf("fd1: %d\n", fd1);
char buffer[BUFFER_SIZE] = "257 NIHAO";
int fd2 = open(name, O_RDWR | O_CREAT, 0644);
if (fd2 == -1)
{
perror("open error");
exit(-1);
}
printf("fd2: %d\n", fd2);
int fd3 = open(name, O_RDWR | O_CREAT, 0644);
if (fd3 == -1)
{
perror("open error");
exit(-1);
}
printf("fd3: %d\n", fd3);
int fd4 = open(name, O_RDWR | O_CREAT, 0644);
if (fd4 == -1)
{
perror("open error");
exit(-1);
}
printf("fd4: %d\n", fd4);
printf("hello wworld\n");
return 0;
}
(2)close函数
-
函数定义
-
close()函数作用
-
如果打开的文件1不使用,就需要关闭所打开的文件1,并且如果又新建一个文件2,那么此文件2的位置就会放在刚刚关闭原文件1的位置。
-
节省资源,提高效率。
-
-
代码段
/*关闭文件描述符 资源回收*/ close(fd3); int fd5 = open(name, O_RDWR | O_CREAT, 0644);//会替换fd3,因为fd3被关了,资源回收 if (fd5 == -1) { perror("open error"); exit(-1); } printf("fd5: %d\n", fd5);
(3)write函数
-
函数定义
-
这里的buf是传入参数。
-
-
代码块
-
O_APPEND: 追加,不写这个那么之前的的写入的数据就会被覆盖
-
#include <stdio.h> #include <fcntl.h> #include <errno.h> #include <stdlib.h> #include <unistd.h> #include <string.h> #include <sys/types.h> #include <sys/stat.h> #define BUFFER_SIZE 32 int main() { const char *name = "./test.txt"; int fd1 = open(name, O_RDWR | O_CREAT | O_APPEND, 0644);//O_APPEND:追加,不写这个那么之前的的数据就会被覆盖 if (fd1 == -1) { perror("open error"); exit(-1); } printf("fd1: %d\n", fd1); char buffer[BUFFER_SIZE] = "257 NIHAO"; write(fd1, buffer, strlen(buffer) + 1); /*必须回收资源*/ close(fd1); printf("hello wworld\n"); return 0; }
(4)read函数
-
函数定义
-
这里的buf是传出参数
-
传出去的是实际的字节个数
-
buf就是缓冲区,没有缓冲区就定义一个
-
-
函数作用
-
read 函数不会添加字符串终止符('\0')。因此,在使用 read 读取的字符串时,你需要确保你知道读取了多少字节,并在合适的位置添加字符串终止符。
-
读取字节的个数记得sizeof(buffer) - 1,因为最后一个是'\0'
-
#include <stdio.h> #include <fcntl.h> #include <errno.h> #include <stdlib.h> #include <unistd.h> #include <string.h> #define BUFFER_SIZE 32 int main() { const char *name = "./test.txt"; int fd1 = open(name, O_RDWR | O_CREAT | O_APPEND, 0644);//O_APPEND:追加,不写这个那么之前的的数据就会被覆盖 if (fd1 == -1) { perror("open error"); exit(-1); } printf("fd1: %d\n", fd1); char buffer[BUFFER_SIZE] = {0}; int readBytes = read(fd1, buffer, sizeof(buffer) - 1); printf("readBytes:%d, \t buffer:%s\n", readBytes, buffer); /*完整打印出全部字符*/ for (int idx = 0; idx < sizeof(buffer); idx++) { printf("%c ", buffer[idx]); } printf("\n"); /*必须回收资源*/ close(fd1); return 0; }
-
read读取'\0'问题
-
当你使用read函数读取文件内容到缓冲区时,如果文件内容中的某个位置恰好是\0,那么read函数会将它当作字符串的结束,后续的内容将不会被当作字符串的一部分来处理。
-
如果你想要读取整个文件内容,包括其中的\0字符,你需要以字节为单位来处理,而不是以字符串为单位。这意味着你不能简单地将读取的内容当作C风格的字符串(即以\0终止的字符数组)来处理。
-
请注意,在打印每个字节时,使用的是%c格式说明符,它会按照字符的原始值进行打印,而不管它是否是空字符。如果我们使用%s格式说明符,那么它会在遇到第一个\0时停止打印,因此\0后面部分将不会被输出。
-
因此,上面程序若想完整打印出字符串,必须使用循环打印每个字符。
/*完整打印出全部字符*/ for (int idx = 0; idx < sizeof(buffer); idx++) { printf("%c ", buffer[idx]); } printf("\n");
-
4.1 循环read
-
思想
-
同循环实现,直到没有读取到字符就结束循环,即readBytes = 0;
-
readBytes是实际读取的字节数,若为0,则说明上一次就已经读取过了,没有字符需要读取,直接跳出循环,这是一种特殊情况。
-
-
每循环一次,若读取超过的BUFFER_SIZE - 1数据时,就直接跳过判断,直接打印BUFFER_SIZE - 1长度的数据,如果只读取比BUFFER_SIZE - 1小的位置,buffer是则在该位置添加\0结束符,再执行打印。
-
然后继续循环,直到读取不到字节为止
开始读取31,下一个循环再读取31个字节,最后将剩余的读取出来。
-
-
read 函数从文件 test.txt 中读取最多 BUFFER_SIZE- 1 个字节,并将它们存储在 buffer 中。然后,我们在读取的字节序列的末尾添加一个字符串终止符(\0),这样 buffer 就成了一个合法的 C 字符串。最后,我们打印出读取的字节数和字符串内容。
-
-
代码块
#include <stdio.h>
#include <fcntl.h>
#include <errno.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/types.h>
#include <sys/stat.h>#define BUFFER_SIZE 32
int main()
{
const char *name = "./test.txt";int fd1 = open(name, O_RDWR | O_CREAT | O_APPEND, 0644); // O_APPEND:追加,不写这个那么之前的的数据就会被覆盖
if (fd1 == -1)
{
perror("open error");
exit(-1);
}
printf("fd1: %d\n", fd1);char buffer[BUFFER_SIZE] = {0};
int readBytes = 0;
while (1)
{
readBytes = read(fd1, buffer, BUFFER_SIZE - 1);减去一个字节用于存储字符串终止符
if (readBytes == 0)
{
/*上一次就已经读取完毕*/
break;
}
else
{
if (readBytes < BUFFER_SIZE - 1)
{
buffer[readBytes] = '\0';
}printf("readBytes:%d, \t buffer:%s\n", readBytes, buffer);
}
}printf("readBytes:%d, \t buffer:%s\n", readBytes, buffer);
/*必须回收资源*/
close(fd1);return 0;
}
(5)命令行参数
-
含义
-
argc是一个整数,表示命令行参数的数量(包括程序名称),argv是一个指向字符指针数组的指针,每个指针指向一个命令行参数。通过argv数组,程序可以访问每个参数的值。
命令行输入三个参数,指针对应三个参数
-
代码段
#include <stdio.h>
#include <fcntl.h>
#include <errno.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/types.h>
#include <sys/stat.h>#define BUFFER_SIZE 32
int main(int argc, const char *argv[])
{
printf("argc: %d\n", argc);for(int idx = 0; idx < argc; idx++)
{
printf("argv[%d]: %s\n", idx, argv[idx]);}
return 0;
}(6)自实现cp函数
-
通过命令行参数和文件读写操作,实现cp函数功能
-
./mycp 文件1 文件2,三个命令行参数,所以若不等3,这是错误
- 编辑
-
读多少写入多少
-
md5sum判断文件是否一模一样(文件标识),如果修改某一文件的内容,则就会不一样
代码段
#include <stdio.h>
#include <fcntl.h>
#include <errno.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/types.h>
#include <sys/stat.h>
#define BUFFER_SIZE 32
int main(int argc, const char *argv[])
{
if(argc != 3)
{
printf("error");
return -1;
}
const char *srcName = argv[1];
const char *dstName = argv[2];
int srcfd = open (srcName, O_RDONLY );
if(srcfd == -1)
{
perror("srcname open error");
return -1;
}
int dstfd = open (dstName, O_RDONLY | O_CREAT, 0644);
if(dstfd == -1)
{
perror("dstname open error");
close(dstfd);
return -1;
}
char buffer[BUFFER_SIZE] = {0};
int readBytes = 0;
while(1)
{
readBytes = read(srcfd, buffer, BUFFER_SIZE - 1);
if(readBytes < 0)
{
perror("read error");
break;
}
else if(readBytes = 0)
{
break;
}
else if(readBytes > 0)
{
write(dstfd, buffer, readBytes);
}
}
/*回收资源*/
close(srcfd);
close(dstfd);
return 0;
}
(7)lseek函数
-
函数定义
函数用法
#include <stdio.h>
#include <fcntl.h>
#include <errno.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#define BUFFER_SIZE 5
int main()
{
const char *name = "./test.txt";
int fd1 = open(name, O_RDWR);
if (fd1 == -1)
{
perror("open error");
exit(-1);
}
printf("fd1: %d\n", fd1);
char buffer[BUFFER_SIZE] = {0};
int readBytes = read(fd1, buffer, sizeof(buffer) - 1);
printf("readBytes:%d\t buffer:%s\n", readBytes, buffer);
// int offest = lseek(fd1, 0, SEEK_END);//可以计算文件的大小
// printf("offest:%d\n", offest);
int offest1 = lseek(fd1, -1, SEEK_CUR);//左移一个
char ch = '0';
read(fd1, &ch, 1);
printf("ch: %c\n", ch);
/*必须回收资源*/
close(fd1);
return 0;
}
(8)stat函数
-
函数定义
-
buf是返回的文件信息
文件类型需要使用下面宏指令去判断,然后输出0则是这个类型,若为1则不是。
-
函数作用
-
用来获取文件的状态信息,比如在配置文件中,如果修改了配置文件,相应的也要修改其他文件,但是判断该配置文件有没有修改,就需要用stat函数去判断。
-
判断修改时间等。
-
代码段
-
time_t的使用必须使用ctime()函数打印,不然会出现总共多少秒,而不是年月日。
#include <stdio.h>
#include <fcntl.h>
#include <errno.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <time.h>
#define BUFFER_SIZE 32
int main()
{
const char *name = "./test.txt";
struct stat bufferStat;
memset(&bufferStat, 0, sizeof(bufferStat));
stat(name, &bufferStat);
/*文件大小*/
printf("size: %ld\n", bufferStat.st_size);
/*文件类型: 普通文件*/
printf("mode: %d\n", S_ISDIR(bufferStat.st_mode));
/*时间打印,最后一次访问时间*/
localtime(&bufferStat.st_atime);
printf("st_atime:%s \n", ctime(&bufferStat.st_atime));
/*时间打印,最后一次修改时间:更改内容*/
localtime(&bufferStat.st_mtime);
printf("st_mtime:%s \n", ctime(&bufferStat.st_mtime));
/*时间打印 最后一次被更改时间:更改属性*/
localtime(&bufferStat.st_ctime);
printf("st_ctime: %s\n", ctime(&bufferStat.st_ctime));
return 0;
}
(9)access函数
-
函数定义
-
函数用法
-
大多数用来测试文件是否存在 F_OK
-
代码段
#include <stdio.h> #include <fcntl.h> #include <errno.h> #include <stdlib.h> #include <unistd.h> #include <string.h> #define BUFFER_SIZE 32 int main() { const char *name = "./test.txt"; /*判断文件是否存在*/ int ret = access(name, F_OK); printf("ret:%d\n", ret); /*判断文件是否有读权限*/ ret = access(name, R_OK); /*判断文件是否写权限*/ ret = access(name, W_OK); /*判断文件是否有执行权限*/ ret = access(name, X_OK); printf("ret:%d\n", ret); return 0; }
(10)DIR
-
DIR
结构体通常是由<dirent.h>
或<direct.h>
头文件(取决于你的编译器和平台)定义的,但它是一个不透明的结构体,意味着其内部成员并不直接暴露给程序员。你通常使用一组与DIR
结构体相关的函数来操作它,如opendir()
,readdir()
,closedir()
等。 -
然而,为了帮助你理解其概念,我们可以假设一个简化的
DIR
结构体可能如下所示(注意:这不是实际的定义,只是为了说明概念):
// 这是一个简化的 DIR 结构体示例,实际定义可能因平台或库而异 typedef struct DIR { // 当前目录的句柄或标识符 void* handle; // 指向当前读取到的目录项的指针 struct dirent* current_entry; // 其他可能的内部状态信息,如读取位置、错误代码等 // ... } DIR; // dirent 结构体通常用于表示目录中的一个项(文件或子目录) typedef struct dirent { // 文件名或目录名的长度(不包括终止的空字符) ino_t d_ino; // inode number(在某些系统上可能不存在) off_t d_off; // 偏移量到下一个 dirent 的位置(在某些系统上可能不存在) unsigned short d_reclen; // 此 dirent 结构体的长度 unsigned char d_type; // 文件类型(如 DT_REG, DT_DIR 等) char d_name[256]; // 文件名(包括空字符) } dirent;
10.1 定义
-
DIR结构体是一个在C语言中与目录操作相关的数据结构,它用于保存当前正在被读取的目录的有关信息。
-
DIR结构体类似于FILE结构体,是一个内部结构,通常在使用readdir等函数时用到。
-
在这个结构体中,各个成员的含义如下:
-
__fd:指向与目录流相关的文件描述符的指针。
-
__data:指向存储目录条目的缓冲区的指针。
-
entry_data:一个标志,指示data是否包含有效的目录条目数据。
-
__ptr:指向当前正在处理的目录条目的指针。
-
entry_ptr:一个内部索引,用于跟踪data缓冲区中的当前条目。
-
allocation:分配给data缓冲区的总字节数。
-
size:当前data缓冲区中有效数据的字节数。
-
__lock:用于同步访问目录流的锁。
-
-
DIR结构体通常与readdir函数一起使用,readdir函数用于从已打开的目录流中读取下一个目录条目,并将其存储在dirent结构体中。这样,通过连续调用readdir函数并传递DIR结构体作为参数,可以遍历整个目录并获取每个文件和子目录的信息。
-
-
readdir 函数是一个在C语言中用于读取目录内容的函数。它通常与 DIR 结构体一起使用,DIR 结构体是通过 opendir 函数打开的目录流的抽象表示。
10.2 用法及步骤
include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <errno.h>
#include <unistd.h>
#include <string.h>
#include <dirent.h>
#define BUFFER_SIZE 32
int main()
{
const char *name = "/home/TAO/operation-system/FileIO/testDIR/music";
char buffer[BUFFER_SIZE] = {0};
getcwd(buffer, BUFFER_SIZE - 1);
printf("buffer:%s\n", buffer);
DIR *dir = opendir(name);
if (dir = NULL)
{
perror("opendir error");
return -1;
}
/*文件打开成功*/
struct dirent *musicdir = readdir(dir);
while ((musicdir = readdir(dir)) != NULL)
{
if (musicdir->d_type != DT_DIR)
{
printf("d_type:%d d_name:%s\n", musicdir->d_type, musicdir->d_name);
}
}
/*关闭文件夹*/
closedir(dir);
return 0;
}
(11)getcwd函数
(12)truncate函数
-
函数定义
-
通常它指的是调整文件大小的操作。在C语言的文件I/O中,truncate 函数用于更改已存在文件的大小。
-
代码段
#include <stdio.h>
#include <fcntl.h>
#include <errno.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include<sys/types.h>
#define BUFFER_SIZE 32
int main()
{
const char *name = "./test.txt";
int ret = truncate(name, 3);
printf("ret:%d\n", ret);
return 0;
}
(13)strdup函数
-
strdup
是 C 语言中的一个标准库函数,通常用于动态分配内存并复制一个给定的字符串到新分配的内存中。这个函数在字符串的末尾会自动添加一个空字符\0
以标记字符串的结束。 -
以下是
strdup
函数的详细说明: -
函数原型
-
#include <string.h> char *strdup(const char *str);
-
-
功能
-
strdup
函数接受一个指向以\0
结尾的字符串的指针str
作为参数,即要复制的字符串。 -
它动态地分配足够的内存(使用
malloc
或其等价物)来存储复制品,包括额外的终止空字符。 -
然后,它将源字符串的内容(包括终止空字符)复制到新分配的内存中。
-
最后,它返回一个指向新分配的内存的指针,该内存现在包含与源字符串相同的字符串。
-
-
使用注意事项
-
使用完
strdup
分配的内存后,必须使用free
函数来释放它,以避免内存泄漏。 -
strdup
的参数不能是NULL
。如果传递了NULL
指针,函数的行为是未定义的,并且可能会导致程序崩溃或错误。 -
由于
strdup
使用了malloc
或其等价物来分配内存,因此如果系统内存不足,它可能会失败并返回NULL
。在调用strdup
后,应检查返回值是否为NULL
,以确保内存分配成功。
-
-
示例
#include <stdio.h> #include <stdlib.h> #include <string.h> int main() { const char *original = "Hello, world!"; char *copy = strdup(original); if (copy == NULL) { fprintf(stderr, "Memory allocation failed\n"); return 1; } printf("Original: %s\n", original); printf("Copy: %s\n", copy); free(copy); // Remember to free the allocated memory return 0; }
5、文件输入与输出
(1)fopen函数
-
函数定义
-
用于在文件系统中打开一个文件,并返回一个文件指针,以便后续的文件操作(如读取、写入等)。
-
文件打开模式
-
"r":以只读方式打开文件。文件必须存在。
-
"r+":以读写方式打开文件。文件必须存在。
-
- **"w":以只写方式打开文件。如果文件存在,则清空文件内容;如果文件不存在,则创建新文件。** - **"w+":以读写方式打开文件。如果文件存在,则清空文件内容;如果文件不存在,则创建新文件。** - **"a":以追加方式打开文件用于写入。如果文件存在,数据会被添加到文件末尾;如果文件不存在,则创建新文件。** - **"a+":以读写方式打开文件用于追加。如果文件存在,数据会被添加到文件末尾;如果文件不存在,则创建新文件。读取操作会从文件开头开始。**
- "x":以只写方式创建新文件。如果文件已存在,则打开文件失败。
- "x+":以读写方式创建新文件。如果文件已存在,则打开文件失败。
- "b":与上述模式结合使用,表示以二进制模式打开文件。例如,"rb" 表示以二进制模式只读方式打开文件。
- "t":与上述模式结合使用,表示以文本模式打开文件。这是默认模式,通常可以省略。例如,"rt" 表示以文本模式只读方式打开文件。
-
需要注意的是,当使用 "w"、"w+"、"x" 或 "x+" 模式打开文件时,如果文件已存在,其内容将被清空。如果希望保留原有文件内容并追加新数据,应该使用 "a" 或 "a+" 模式。
-
代码段
#include <stdio.h> int main() { const char *name = "test.txt"; FILE *fp = fopen(name, "w+"); if (fp == NULL) { perror("fopen error\n"); return -1; } char buffer[] = "hello world\n"; fwrite(buffer, 1, sizeof(buffer), fp); /*关闭文件*/ fclose(fp); return 0; }
6、open与fopen的区别
-
在C语言中,open和fopen都是用于打开文件的函数,但它们属于不同的库,并且在使用方式和功能上有所区别。
-
所属库:
-
open函数是Unix/Linux系统调用的一部分,通常定义在fcntl.h或sys/types.h和sys/stat.h中。
-
fopen函数是C标准库中的函数,定义在stdio.h中。
-
-
使用方式:
-
open函数通常用于低级文件操作,它返回一个文件描述符(一个小的非负整数),用于后续的文件操作。例如:int fd = open("filename.txt", O_RDONLY);
-
fopen函数返回一个FILE指针,这是一个指向FILE结构体的指针,该结构体包含了用于文件操作的缓冲区和其他信息。例如:FILE *fp = fopen("filename.txt", "r");
-
-
错误处理:
-
当open失败时,它返回-1,并设置全局变量errno以指示错误。你可以使用perror或strerror来获取错误描述。
-
当fopen失败时,它返回NULL。你可以使用ferror和clearerr来处理错误。
-
-
模式:
-
open函数使用标志(如O_RDONLY, O_WRONLY, O_RDWR, O_CREAT, O_TRUNC等)来指定打开文件的模式。
-
fopen函数使用字符串(如"r", "w", "a", "r+"等)来指定打开文件的模式。
-
-
功能:
-
open是一个更底层的文件操作函数,通常用于系统编程或需要与操作系统进行更直接交互的情况。
-
fopen提供了更高级别的文件操作,包括缓冲和格式化输入/输出。
-
-
关闭文件:
-
对于open打开的文件,使用close函数来关闭。
-
对于fopen打开的文件,使用fclose函数来关闭。
-
-
总的来说,open和fopen都用于打开文件,但fopen提供了更高级别的接口,适合大多数常规的文件操作,而open则提供了更底层的接口,适合需要更多控制和与操作系统直接交互的情况。在大多数情况下,开发者会倾向于使用fopen,因为它提供了更易于使用的接口和更多的功能。
7、fprintf 和 fflush
-
fprintf
和fflush
是 C 语言中标准库提供的两个函数,用于文件操作和缓冲区管理。
(1)fprintf
函数
-
fprintf
函数用于将格式化的输出写入一个指定的文件流。它的原型如下:-
int fprintf(FILE *stream, const char *format, ...);
-
-
stream
:一个指向FILE
对象的指针,它指定了fprintf
函数写入的文件流。这可以是标准输出stdout
、标准错误stderr
,或者由fopen
、freopen
或fdopen
函数打开的文件流。 -
format
:一个格式字符串,它包含了将被插入到输出中的文本以及格式说明符。 -
...
:根据格式字符串中的格式说明符,这里需要提供相应的参数。 -
fprintf
函数返回写入的字符数(不包括终止的空字符),如果发生错误则返回负值。
(2)fflush
函数
-
fflush
函数用于刷新一个流。它的原型如下: -
int fflush(FILE *stream);
-
stream
:一个指向FILE
对象的指针,它指定了需要刷新的流。如果stream
是NULL
,则fflush
会刷新所有打开的输出流。 -
fflush
函数用于确保所有挂起的输出都被实际写入到其目标设备。这在你想要立即看到输出时很有用,例如,在打印调试信息或进度更新时。对于输入流,fflush
的行为是未定义的。
-
-
fflush
函数成功时返回0
,失败时返回EOF
。
(3)示例
-
下面是一个简单的示例,它展示了如何使用
fprintf
和fflush
:
#include <stdio.h> int main() { FILE *file = fopen("example.txt", "w"); if (file == NULL) { perror("Error opening file"); return 1; } fprintf(file, "Hello, World!\n"); fflush(file); // 确保数据被写入文件 // 更多的文件操作... fclose(file); // 关闭文件 return 0; }
在这个例子中,fprintf
用于将字符串 "Hello, World!\n" 写入文件 example.txt
。fflush
函数用于确保这个字符串立即被写入文件,而不是等待缓冲区满或者文件被关闭。最后,使用 fclose
关闭文件。
8、日志调试
-
改系统文件时,一定要备份一份,避免改坏了之后没有替换的文件。
-
日志调试在软件开发和运维过程中起到了重要的作用,它主要用于记录程序运行过程中的详细信息,帮助开发人员和运维人员定位问题、排查逻辑错误以及了解程序运行流程。以下是日志调试的主要作用和使用方法:
-
一、日志调试的主要作用
-
打印调试:日志可以记录程序运行的流程,包括变量或某一段逻辑的执行情况,这有助于排查逻辑问题。
-
问题定位:当程序出现异常或故障时,通过查看日志记录的信息,可以快速定位问题所在,方便后期解决。
-
用户行为日志:记录用户的操作行为,用于大数据分析,比如监控、风控、推荐等。这种日志通常用于其他团队的分析使用,因此需要按照约定的格式来记录。
-
根因分析:在关键地方记录日志,有助于定位问题的根源,避免互相推脱责任。
-
-
二、日志调试的使用方法
-
设定日志级别:根据需求设定不同的日志级别,如DEBUG、INFO等。DEBUG级别最详细,主要用于开发阶段的调试;INFO级别通常用来记录系统运行的关键事件或正常流程信息。
-
设计好日志语句:需要输出的日志数量是一个简约与信息量的权衡。日志中的每个记录应标记其在源代码里的位置、执行它的线程(如果可用的话)、时间精度,并且通常还应包含一些额外的有效信息,如变量的值、剩余内存大小、数据对象的数量等。
-
使用日志框架:如log4j等,可以控制日志信息输送的目的地(如控制台、文件、GUI组件),定义每一条日志信息的级别,以及配置日志的输出格式。
-
定期查看和分析日志:定期查看和分析日志,可以帮助发现潜在的问题和优化程序的性能。
-
-
-
编写一个日志调试文件通常涉及以下几个步骤:
-
选择或实现日志库:你可以使用现有的日志库,比如
log4c
,或者自己实现一个简单的日志系统。 -
定义日志级别:例如,DEBUG、INFO、WARN、ERROR等。
-
实现日志输出函数:这些函数负责将日志信息写入文件或控制台。
-
在代码中插入日志语句:在代码的关键位置插入日志语句,以便在运行时记录信息。
-
#include <stdio.h> #include <time.h> #include <unistd.h> #include <fcntl.h> #include <error.h> /* 调试标记是否存在 */ int g_debug = 0; /* 文件指针 */ FILE *g_logfp = NULL; #define LOGPR(fmt, args...) \ do \ { \ if (g_debug) \ { \ time_t now; \ struct tm *ptm = NULL; \ now = time(NULL); \ ptm = localtime(&now); \ fprintf(g_logfp, "[file:(%s), func(%s), line(%d), time(%s)]: "fmt, \ __FILE__, __FUNCTION__, __LINE__, asctime(ptm), ##args); \ fflush(g_logfp); \ } \ }while(0) /* 日志 : 就是文件 */ /* 打开日志文件 /*/ void log_init() { time_t now; /* 避免野指针 */ struct tm *ptm = NULL; #define DEBUG_FLAG "./my_debug.flag" /* access函数 成功返回0 */ if (access(DEBUG_FLAG, F_OK) == 0) { g_debug = 1; } if (!g_debug) { return; } #define DEBUG_FILE "/var/log/test_main.log" if ((g_logfp = fopen(DEBUG_FILE, "a")) == NULL) { perror("fopen error"); return; } now = time(NULL); ptm = localtime(&now); LOGPR("=====================log init done.===================="); LOGPR("=================%s", asctime(ptm)); return; } /* 关闭文件 */ void log_close() { if (g_logfp) { fclose(g_logfp); g_logfp = NULL; } } int main() { /* 启动日志程序 */ log_init(); int count = 50; while(count--) { LOGPR("count = %d\n", count); sleep(2); } /* 关闭文件 */ log_close(); return 0; }
操作系统
1、进程
(1)top
-
top 可以查看进程状态
-
同Windows任务管理器
(2)进程
-
进程是在运行过程的程序,如果该程序结束,就不存在进程,因此使用代码演示时采用sleep()函数将程序休眠一段时间在终止,以便查看进程。
(3)ps - ef | grep 命令
-
ps - ef 查看进程
-
grep 是过滤,如果想查看某一文件的进程,使用该命令查看
-
grep -v是忽视
(4)PID与PPID
(5)kill
-
kill -9 是一个Linux命令,用于强制终止一个进程。当你在终端中输入 kill -9 <PID> 并按下回车后,系统会发送一个强制终止信号(SIGKILL)给指定进程的进程ID(PID),以立即终止该进程。
-
注意:使用 kill -9 命令会立即终止目标进程,而不给进程任何机会保存数据或执行清理操作。这是一种非常强硬的终止方式,应该谨慎使用。通常情况下,应该首先尝试使用 kill <PID> 命令发送默认的终止信号(SIGTERM)给进程,让进程有机会优雅地退出。只有在无法正常终止进程,或者需要立即停止一个非响应的进程时,才考虑使用 kill -9。
(6)后台运行
-
加上&符号就是后台运行
-
当你使用 ./main & 命令来在后台运行一个程序(例如一个名为 main 的可执行文件),该命令会立即返回命令行的控制权给你,而 main 程序会在后台开始执行。由于它是在后台运行的,因此你不会在终端中直接看到该程序的输出,除非你明确地将其重定向到某个文件或终端。
(7)进程的状态
-
运行态和就绪态都是CPU执行
(8)ps命令
-
ps -auxf
-
进程状态信息
(9)进程号和相关函数
-
每个文件进程都对应的一个进程号,其进程号的维护在proc文件里
(10)free 与 free -h
-
-h:是人性化的意思,free -h 可以将显示内存占了具体大小。
-
其他命令加上-h也会显示人性化的内容。
(11)getpid函数 与 getppid函数
-
前者是子进程号pid,后者是父进程号ppid
所有进程的老大是祖先进程init()进程
(12)进程退出函数
(13)代码段
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
/*什么是程序 什么叫代码 什么叫进程*/
/*什么情况下会导致CPU飙升:死循环*/
int main()
{
int fd = open("./makefile", O_RDONLY);
pid_t pid = getpid();
printf("pid is = %d\n", pid);
pid_t ppid = getppid();
printf("ppid is = %d\n", ppid);
int count = 20;
while (count--)
{
sleep(1);
}
/*休眠,让出CPU资源*/
// sleep(1);
return 0;
}
2、多进程的创建
(1)fork函数
-
注意它的返回值,成功后,在父进程中返回子进程的PID ,在子进程中返回0。失败时,在第1段中返回-1,父进程时,不会创建任何子进程,并且正确设置了errno。
-
fork函数一旦执行进程就会创建,而且父进程和子进程都是独立的内存空间,因此能够同时进行。
(2)父子进程关系
-
使用fork(函数得到的子进程是父进程的一个复制品,它从父进程处继承了整个进程的地址空间:包括进程上下文(进程执行活动全过程的静态描述)、进程堆栈、打开的文件描述符、信号控制设定、进程优先级、进程组号等。
-
子进程所独有的只有它的进程号,计时器等(只有小量信息)。因此,使用fork(函数的代价是很大的。
-
简单来说,一个进程调用fork()函数后,系统先给新的进程分配资源,例如存储数据和代码的空间。然后把原来的进程的有值都复制到新的新进程中,只有少数值与原来的进程的值不同。相当于克隆了一个自己。
-
实际上,更准确来说,Linux的fork0使用是通过写时拷贝(copy- on-write)实现。写时拷贝是一种可以推迟甚至避免拷贝数据的技术。内核此时并不复制整个进程的地址空间,而是让父子进程共享同一个地址空间。 只用在需要写入的时候才会复制地址空间,从而使各个进行拥有各自的地址空间。也就是说,资源的复制是在需要写入的时候才会进行,在此之前,只有以只读方式共享。
-
注意: fork之后父子进程共享文件,fork产生的子进程与父进程相同的文件文件描述符指向相同的文件表,引用计数增加,共享文件文件偏移指针。
(3)代码段
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
/**
* 多进程,并发处理
* 1、(优点)干活快 ------(效率)
* 2、(缺点)消耗资源 -------(资源)
* 3、压力测试(性能瓶颈)
**
*/
int main()
{
#if 1
//守护进程:子进程和父进程同时进行
pid_t pid = fork(); //vfork()让父进程先进行
if (pid < 0)
{
perror("fork error\n");
return -1;
}
else if (pid == 0)
{
usleep(1000); //1000us = 1 ms = 0.001s
/*子进程和父进程执行顺序是随机的,谁先打印由操作系统打印*/
printf("child process\n");
printf("child: pid is = %d\t parent: ppid is = %d\n", getpid(), getppid());
while (1)
{
sleep(1);
}
}
else
{
printf("pid:%d\n", pid);
printf("parent process\n");
/*子进程和父进程执行顺序是随机的,谁先打印由操作系统打印*/
printf("parent :pid is = %d\t parent :ppid is = %d\n", getpid(), getppid());
while (1)
{
sleep(1);
}
}
#endif
#if 0
//父进程结束导致孤儿进程
pid_t pid = fork();
if (pid < 0)
{
perror("fork error\n");
return -1;
}
else if (pid == 0)
{
printf("child process\n");
printf("child: pid is = %d\t parent: ppid is = %d\n", getpid(), getppid());
while (1)
{
sleep(1);
}
}
else
{
printf("parent process\n");
printf("parent :pid is = %d\t parent :ppid is = %d\n", getpid(), getppid());
}
/*这种情况是父进程结束了,子进程没人来回收(孤儿进程),会交给系统管理(自动回收)*/
#elseif 0
// 子进程结束导致僵尸进程
pid_t pid = fork();
if (pid < 0)
{
perror("fork error\n");
return -1;
}
else if (pid == 0)
{
printf("child process\n");
printf("child: pid is = %d\t parent: ppid is = %d\n", getpid(), getppid());
}
else
{
printf("parent process\n");
printf("parent :pid is = %d\t parent :ppid is = %d\n", getpid(), getppid());
while (1)
{
sleep(1);
}
}
/*总结子进程结束,父进程没有回收他的资源,就会导致子进程就是僵尸进程,危害:资源依旧被占用,没有释放*/
return 0;
}
#endif
3、独立空间
(1)局部变量
-
局部变量中,子进程是拿不到父进程中的数据,因为是独立的空间。
(2)全局变量
-
与上面同理
(3)通信
-
可以通过文件读写操作让子进程读取到父进程的值,将父进程的数据写入文件,子进程读取文件。
(4)代码段
#include <stdio.h>
#include <fcntl.h>
#include <errno.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
/*全局变量*/
int g_data = 666;
int main()
{
/*局部变量*/
int val = 100;
#if 0
/*创建进程*/
pid_t pid = fork();
if (pid < 0)
{
perror("fork error");
}
else if (pid == 0)
{
sleep(1);//这样就能保证父进程先执行
/*子进程*/
printf("child: val:%d\n", val);
printf("child: g_data:%d\n", g_data);
/*将文件数据读取*/
int fd = open("fork.txt", O_RDONLY);
if (fd == -1)
{
perror("open error");
exit(-1);
}
int data = 0;
int readBytes = read(fd, (void *)&data, sizeof(int));
if (readBytes < 0)
{
}
else if(readBytes == 0)
{
}
else
{
printf("g_data: %d\n", data);
}
printf("readbytes: %d\n", readBytes);
/*必须回收资源*/
close(fd);
if(g_data == 1000)
{
}
}
else
{
/*父进程*/
printf("parent: val:%d\n", val);
printf("parent: g_data:%d\n", g_data);
val += 666;
printf("parent: val:%d\n", val);
g_data += 222;
printf("parent: g_data:%d\n", g_data);
/*将数据写入文件*/
int fd = open("fork.txt", O_WRONLY | O_CREAT, 0644);
if (fd == -1)
{
perror("open error");
exit(-1);
}
int writeBytes = write(fd, &g_data, sizeof(int));
if (writeBytes == -1)
{
perror("write error");
return -1;
}
printf("writebytes: %d\n", writeBytes);
/*必须回收资源*/
close(fd);
}
sleep(3);
#endif
// 这个开一个fd太麻烦了拿,建议用上面的方法。
/*将数据写入文件*/
int fd = open("fork.txt", O_RDWR | O_CREAT, 0644);
if (fd == -1)
{
perror("open error");
exit(-1);
}
/*创建进程*/
pid_t pid = fork();
if (pid < 0)
{
perror("fork error");
}
else if (pid == 0)
{
sleep(1); // 这样就能保证父进程先执行
/*子进程*/
printf("child: val:%d\n", val);
printf("child: g_data:%d\n", g_data);
/*将文件数据读取*/
lseek(fd, 0, SEEK_SET); // 文件指针复原到开头
int data = 0;
int readBytes = read(fd, (void *)&data, sizeof(int));
if (readBytes < 0)
{
perror("read error");
return -1;
}
else if (readBytes == 0)
{
printf("readBytes = 0");
}
else
{
printf("g_data: %d\n", data);
}
printf("readbytes: %d\n", readBytes);
if (g_data == 1000)
{
}
}
else
{
/*父进程*/
printf("parent: val:%d\n", val);
printf("parent: g_data:%d\n", g_data);
val += 666;
printf("parent: val:%d\n", val);
g_data += 222;
printf("parent: g_data:%d\n", g_data);
/*写入文件*/
int writeBytes = write(fd, &g_data, sizeof(int));
if (writeBytes == -1)
{
perror("write error");
return -1;
}
printf("writebytes: %d\n", writeBytes);
}
sleep(10);
/*关闭文件描述符*/
close(fd);
sleep(3);
return 0;
}
4、等待子进程进程退出函数
(1)概述
-
在每个进程退出的时候,内核释放该进程所有的资源、包括打开的文件、占用的内存等。但是仍然为其保留一定的信息, 这些信息主要主要指进程控制块PCB的信息(包括进程号、退出状态、运行时间等)。
-
父进程可以通过调用wait或waitpid得到它的退出状态同时彻底清除掉这个进程。
-
wait)和waitpid()函数的功能一样,区别在于,wait()函数会阻塞,waitpid(可以设置不阻塞,waitpid)还可 以指定等待哪个子进程结束。
-
注意:一次wait或waitpid调用只能清理一个 子进程,清理多个子进程应使用循环。
(2)wait函数
-
注意wait()函数是回收已经结束的子进程的资源,如果父进程比子进程先结束,那么父进程中的wait()会启用,导致子进程阻塞了。
(3)阻塞函数
3.1 阻塞函数
-
程序卡在这边,在等条件满足,在等子进程退出
3.1.1 阻塞的原因
-
当没有数据时,read就会阻塞。
-
read在等条件是否有数据可读,通知就是条件。是通过内核通知的有数据,需要让read去读
strace -p 查看进程是否堵塞
-
查看进程有没有堵塞,下面是堵塞时的状态
-
strace -p + 进程号
3.2 解决进程阻塞问题
fcntl函数
步骤
-
flag |= O_NONBLOCK;
是C语言中的一个位运算表达式,用于设置flag
变量的某个位。具体来说,它设置了flag
变量中与O_NONBLOCK
对应的位。 -
这里的关键操作是
|=
,这是一个复合赋值运算符,表示按位或,然后赋值。O_NONBLOCK
是一个宏,通常在Unix-like系统的头文件中定义,用于文件或套接字操作,以设置非阻塞模式。 -
flag |= O_NONBLOCK;
这行代码的作用是:确保flag
变量中对应于O_NONBLOCK
的位被设置为1,通常用于将文件描述符或套接字设置为非阻塞模式。
/* 获取文件描述符当前属性 */
int flag = fcntl(pipefd[0], F_GETFL);
/* 设置成非阻塞 */
flag |= O_NONBLOCK;
fcntl(pipefd[0], F_SETFL, flag);
(4)waitpid函数
(5)代码段
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/wait.h>
//int g_status = 100;//全局
int main()
{
// 守护进程:子进程和父进程同时进行
pid_t pid = fork(); // vfork()让父进程先进行
if (pid < 0)
{
perror("fork error\n");
return -1;
}
else if (pid == 0)
{
usleep(1000); // 1000us = 1 ms = 0.001s
/*子进程和父进程执行顺序是随机的,谁先打印由操作系统打印*/
printf("child process\n");
printf("child: pid is = %d\t parent: ppid is = %d\n", getpid(), getppid());
int count = 3;
while (count--)
{
sleep(1);
}
int g_status = 1;//局部
_exit(g_status);
}
else
{
printf("pid:%d\n", pid);
printf("parent process\n");
/*子进程和父进程执行顺序是随机的,谁先打印由操作系统打印*/
printf("child :pid is = %d\t parent :ppid is = %d\n", getpid(), getppid());
/*阻塞函数:程序卡在这边,在等条件满足,在等子进程退出*/
#if 0
int status = 0;
wait(&status);
printf("status: %d\n", WEXITSTATUS(status));
#else
int status = 0;
waitpid(pid, &status, 0);
printf("status: %d\n", WEXITSTATUS(status));
#endif
}
/*子进程退出,但是没有回收子进程的资源*/
printf("hello world");
return 0;
}
5、僵尸进程与孤儿进程
6、IPC
(0)进程间通信
-
因为进程具有独立的地址空间,所以必须有通信机制。IPC
-
进程间通信(Inter-Process Communication, IPC)是指不同进程之间传输数据或同步执行的机制。在操作系统中,有多种方式实现进程间通信,以下是几种常见的方法:
-
管道(Pipe):
-
匿名管道:一种半双工的通信方式,常用于具有亲缘关系的父子进程间通信。由
pipe()
系统调用创建,具有固定的读写方向。
-
-
命名管道(FIFO):允许无关的进程间进行通信,通过文件系统路径来命名,通过mkfifo()
创建。
-
消息队列(Message Queues):
-
允许一个或多个进程通过消息进行通信的一种机制。消息队列独立于发送和接收进程存在,常用于解耦和异步通信。
-
-
共享内存(Shared Memory):
-
允许多个进程访问同一块物理内存,进程可以直接读写共享内存,效率高,但需要额外的同步机制来避免数据一致性问题。
-
-
信号量(Semaphores):
-
用于进程间同步的计数器,可以用来保护共享资源,防止进程之间的竞态条件。
-
-
套接字(Socket):
-
一种用于不同主机或同一主机上不同进程间通信的机制,基于网络协议(如TCP/IP)或本地域协议(如Unix域套接字)实现。
-
1. 消息队列(Message Queues)
-
操作方式:
-
创建消息队列:使用
msgget()
系统调用创建消息队列。需要指定一个key和一组标志来定义队列的属性。int msgget(key_t key, int msgflg);
-
发送消息:使用
msgsnd()
系统调用向消息队列发送消息。需要指定消息队列标识符、消息结构体指针、消息长度和消息类型。int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);
-
接收消息:使用
msgrcv()
系统调用从消息队列接收消息。需要指定消息队列标识符、消息结构体指针、消息长度和消息类型。ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp, int msgflg);
-
删除消息队列:使用
msgctl()
系统调用删除消息队列。int msgctl(int msqid, int cmd, struct msqid_ds *buf);
-
2. 共享内存(Shared Memory)
-
操作方式:
-
创建或获取共享内存:使用
shmget()
系统调用创建或获取一个共享内存段。需要指定key、大小和权限等参数。int shmget(key_t key, size_t size, int shmflg);
-
映射共享内存:使用
shmat()
系统调用将共享内存段连接到当前进程的地址空间中。void *shmat(int shmid, const void *shmaddr, int shmflg);
-
操作共享内存:通过指针直接读写共享内存数据,操作类似于普通的内存操作。
-
解除映射:使用
shmdt()
系统调用解除共享内存段与当前进程的连接。int shmdt(const void *shmaddr);
-
删除共享内存:使用
shmctl()
系统调用删除共享内存段。int shmctl(int shmid, int cmd, struct shmid_ds *buf);
-
3. 信号量(Semaphores)
-
操作方式:
-
创建或获取信号量集:使用
semget()
系统调用创建或获取一个信号量集。需要指定key、信号量数量和权限等参数。int semget(key_t key, int nsems, int semflg);
-
初始化信号量:使用
semctl()
系统调用初始化信号量集中的每个信号量。int semctl(int semid, int semnum, int cmd, ...);
常见的初始化命令包括设置信号量的初始值和获取当前值等。
-
操作信号量:使用
semop()
系统调用对信号量进行操作,如增加(V操作)或减少(P操作)信号量的值。int semop(int semid, struct sembuf *sops, size_t nsops);
-
删除信号量:使用
semctl()
系统调用删除信号量集。
-
(1)管道-无名管道
1.1 无名管道
-
概述
-
管道也叫无名管道,它是UNIX系统IPC(进程间通信)的最古老形式,所有的UNIX系统都支持这种通信机制。
-
管道有如下特点:
-
半双工,数据在同一时刻只能在一个方向上流动。
-
数据只能从管道的一端写入, 从另一端读出。
-
写入管道中的数据遵循先入先出的规则。
-
管道所传送的数据是无格式的,这要求管道的读出方与写入方必须事先约定好数据的格式,如多少字节算一个消息等。
-
管道不是普通的文件,不属于某个文件系统,其只存在于内存中。
-
管道在内存中对应一个缓冲区。不同的系统其大小不一定相同。
-
从管道读数据是一次性操作,数据一旦被读走, 它就从管道中被抛弃,释放空间以便写更多的数据。
-
管道没有名字,只能在具有公共祖先的进程(父进程与子进程,或者两个兄弟进程,具有亲缘关系)之间使用。
-
对于管道特点的理解,我们可以类比现实生活中管子,管子的一端塞东西,管子的另一端取东西。
-
管道是一种特殊类型的文件,在应用层体现为两个打开的文件描述符。
-
-
-
半双工
-
类似独木桥原理
-
1.2 pipe函数
-
pipefd[0]是读管道
-
pipefd[1]是写管道
1.3 代码块
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <string.h>
#include <fcntl.h>
#include <errno.h>
#include <error.h>
/*
为什么要有进程间通信?
因为进程具有独立的地址空间, 所以必须要有通信机制. IPC
*/
#define PIPE_SIZE 2
#define BUFFER_SIZE 32
int main()
{
int pipefd[PIPE_SIZE] = {0};
/* 创建管道 */
int ret = pipe(pipefd);
if (ret == -1)
{
perror("pipe error");
exit(-1);
}
/* 创建进程 */
pid_t pid = fork();
if (pid < 0)
{
perror("fork error");
exit(-1);
}
else if (pid == 0)
{
sleep(2);
/* 子进程读 */
/* pipefd[0]: 读 */
/* 将写端关闭 */
close(pipefd[1]);
/* 获取文件描述符当前属性 */
int flag = fcntl(pipefd[0], F_GETFL);
/* 设置成非阻塞 */
flag |= O_NONBLOCK;
fcntl(pipefd[0], F_SETFL, flag);
char buffer[BUFFER_SIZE] = {0};
int readBytes = read(pipefd[0], buffer, sizeof(buffer) - 1);
if (readBytes < 0)
{
if (errno == EAGAIN)
{
printf("heiheihei\n");
}
else
{
perror("read error");
}
}
else if (readBytes == 0)
{
printf("readBytes == 0\n");
}
else
{
printf("child --- readBytes:%d,\tbuffer:%s\n", readBytes, buffer);
}
/* 关闭读端 */
close(pipefd[0]);
}
else
{
/* 父进程写 */
/* pipefd[1]: 写 */
/* 将读端关闭 */
close(pipefd[0]);
char * str = "hello world";
write(pipefd[1], str, strlen(str) + 1);
/* 回收子进程资源 */
wait(NULL);
/* 关闭写端 */
close(pipefd[1]);
}
return 0;
}
(2)管道-有名管道
2.1 无名与有名的区别
-
无名:管道不是普通的文件,不属于某个文件系统,其只存在于内存中。
-
有名:以FIFO的文件形式存在于文件系统中
2.2 mkfifo函数创建有名管道
2.3 有名管道读写操作
2.4 有名管道的通信
-
有名管道通信可以在不具备亲属进程之间
-
在具有亲属关系进程通信
-
通过fork函数创建进程,使其具备亲属关系。
-
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <errno.h>
#include <error.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <wait.h>
#define BUFFER_SIZE 5
int main()
{
/* 创建命名管道 */
const char * fileName = "./fifoInfo";
int ret = mkfifo(fileName, 0644);
if (ret == -1)
{
if (errno != EEXIST)
{
perror("mkfifo error");
exit(-1);
}
}
/* 创建进程 */
pid_t pid = fork();
if (pid < 0)
{
}
else if (pid == 0)
{
printf("child process\n");
/* 打开管道文件 */
/* 必选项: 只读 / 只写 / 可读可写
可选项: { 追加 | 创建 | 清空 }
*/
int fd = open(fileName, O_RDWR);
if (fd == -1)
{
perror("open error");
}
/* 设置非阻塞 */
int flag = fcntl(fd, F_GETFL)
flag |= O_NONBLOCK;
fcntl(fd, F_SETFL, flag);
char buffer[BUFFER_SIZE] = { 0 };
int readBytes = 0;
while (1)
{
readBytes = read(fd, buffer, BUFFER_SIZE - 1);
if (readBytes < 0)
{
if (errno == EAGAIN)
{
break;
}
else
{
perror("read error");
close(fd);
exit(-1);
}
}
else if (readBytes == 0)
{
printf("readBytes == 0");
break;
}
else
{
printf("readBytes = %d\t, buffer:%s\n", readBytes, buffer);
}
sleep(2);
}
/* 关闭文件描述符 */
close(fd);
}
else
{
printf("parent process\n");
/* 打开管道文件 */
/* 必选项: 只读 / 只写 / 可读可写
可选项: { 追加 | 创建 | 清空 }
*/
int fd = open(fileName, O_RDWR);
if (fd == -1)
{
perror("open error");
}
char * buf = "hello world";
int cnt = 3;
int writeBytes = 0;
while (cnt--)
{
writeBytes = write(fd, buf, strlen(buf) + 1);
if (writeBytes < 0)
{
perror("write error");
}
else if (writeBytes == 0)
{
printf("buffer overflow...\n");
}
else
{
printf("writeBytes = %d\n", writeBytes);
}
sleep(1);
}
/* 关闭文件描述符, 回收资源. */
close(fd);
/* 回收子进程资源 */
wait(NULL);
}
return 0;
}
在不具有亲属关系进程**
- 分别建立读写库,通过将文件至于统一的路径,分别进行写与读
头文件
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <errno.h>
#include <error.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <wait.h>
```
write
#define BUFFER_SIZE 5
int main()
{
const char *fileName = "../fifoInfo";//使用上一目录文件
/*判断文件存不存在*/
int ret1 = access(fileName, F_OK);
if (ret1 == -1)
{
perror("file not exist\n");
}
/* 创建命名管道 */
int ret = mkfifo(fileName, 0644);
if (ret == -1)
{
if (errno != EEXIST)
{
perror("mkfifo error");
exit(-1);
}
}
/* 打开管道文件 */
/* 必选项: 只读 / 只写 / 可读可写
可选项: { 追加 | 创建 | 清空 }
*/
int fd = open(fileName, O_RDWR);
if (fd == -1)
{
perror("open error");
}
char *buf = "hello world";
int cnt = 3;
int writeBytes = 0;
while (cnt--)
{
writeBytes = write(fd, buf, strlen(buf) + 1);
if (writeBytes < 0)
{
perror("write error");
}
else if (writeBytes == 0)
{
printf("buffer overflow...\n");
}
else
{
printf("writeBytes = %d\n", writeBytes);
}
sleep(4);
}
/* 关闭文件描述符, 回收资源. */
close(fd);
return 0;
}
read
#define BUFFER_SIZE 5
int main()
{
const char *fileName = "../fifoInfo"; // 读上一目录文件
/*判断文件存不存在*/
int ret1 = access(fileName, F_OK);
if (ret1 == -1)
{
perror("file not exist\n");
}
/* 创建命名管道 */
int ret = mkfifo(fileName, 0644);
if (ret == -1)
{
if (errno != EEXIST)
{
perror("mkfifo error");
exit(-1);
}
}
/* 打开管道文件 */
/* 必选项: 只读 / 只写 / 可读可写
可选项: { 追加 | 创建 | 清空 }
*/
int fd = open(fileName, O_RDWR);
if (fd == -1)
{
perror("open error");
}
/* 设置非阻塞 */
int flag = fcntl(fd, F_GETFL);
flag |= O_NONBLOCK;
fcntl(fd, F_SETFL, flag);
char buffer[BUFFER_SIZE] = {0};
int readBytes = 0;
while (1)
{
readBytes = read(fd, buffer, BUFFER_SIZE - 1);
if (readBytes < 0)
{
if (errno == EAGAIN)
{
break;
}
else
{
perror("read error");
close(fd);
exit(-1);
}
}
else if (readBytes == 0)
{
printf("readBytes == 0");
break;
}
else
{
printf("readBytes = %d\t, buffer:%s\n", readBytes, buffer);
}
sleep(2);
}
/* 关闭文件描述符 */
close(fd);
return 0;
}
(3)共享存储映射
3.1 概述
-
存储映射I/O (Memory mapped I/O)使一个磁盘文件与存储空间中的一个缓冲区相映射。
-
于是当从缓冲区中取数据,就相当于读文件中的相应字节。于此类似,将数据存入缓冲区,则相应的字节就自动写入文件。这样,就可在不适用read和write函数的情况下,使用地址(指针)完成I/O操作。
-
共享内存可以说是最有用的进程间通信方式,也是最快的IPC形式,因为进程可以直接读写内有而不需要任何数据的拷贝。
3.2 mmap函数(存储映射函数)
关于mmap函数的使用总结:
1)第一个参数写成NULL
2)第二个参数要映射的文件大小> 0
3)第三个参数: PROT_READ 、PROT_WRITE
4)第四个参数: MAP_ SHARED或者MAP_ PRIVATE
5)第五个参数:打开的文件对应的文件描述符
6)第六个参数: 4k的整数倍,通常为0
3.3 munmap函数
3.3 注意事项
-
创建映射区的过程中,隐含着一次对映射文件的读操作。
-
当MAP_SHARED时,要求:映射区的权限应<=文件打开的权限(出于对映射区的保护)。而MAP _PRIVATE则无所谓,因为mmap中的权限是对内存的限制。
-
映射区的释放与文件关闭无关。只要映射建立成功,文件可以立即关闭。
-
特别注意,当映射文件大小为0时,不能创建映射区。所以,用于映射的文件必须要有实际大小。mmap使用时常常会出现总线错误,通常是由于共享文件存储空间大小引起的。
-
munmap传入的地址一定是munmap的返回地址。坚决杜绝指针+ +操作。
-
如果文件偏移量必须为4K的整数倍。
-
mmap创建映射区出错概率非常高,一定要检查返回值,确保映射区建立成功再进行后续操作。
3.4 代码段
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <sys/mman.h>
#include <stdlib.h>
#include <time.h>
#include <string.h>
#include <sys/wait.h>
#include <unistd.h>
int main()
{
/*获取文件大小*/
char *name = "./tongxing.txt";
int fd = open(name, O_RDWR);
if (fd == -1)
{
perror("open error");
exit(-1);
}
/*获取文件大小*/
off_t len = lseek(fd, 0, SEEK_END);
printf("len: %ld\n", len);
/*映射:一个文件映射到内存,addr指向此内存*/
void *addr = mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
if (addr == MAP_FAILED)
{
perror("mmap error");
exit(-1);
}
/*关闭文件*/
close(fd);
/*创建进程*/
pid_t pid = fork();
if (pid < 0)
{
perror("fork error");
exit(-1);
}
else if (pid == 0)
{
/*子进程*/
/*读数据*/
usleep(1000);
printf("buffer: %s \n", (char *)addr);
}
else
{
/*父进程*/
/*写数据*/
char *str = "hello world";
strncpy((char *)addr, str, strlen(str) + 1);
/*回收子进程资源*/
wait(NULL);
}
printf("addr: %p\n", addr);
/*解除映射*/
munmap(addr, len);
return 0;
}
(4)信号
4.1 特点
-
简单
-
不能携带大量信息
-
满足某个特设条件才发送
4.2 概述
-
信号可以直接进行用户空间进程和内核空间进程的交互,内核进程可以利用它来通知用户空间进程发生了哪些系统事件。
-
一个完整的信号周期包括三个部分:信号的产生,信号在进程中的注册,信号在进程中的注销,执行信号处理函数。
4.3 信号的编号
-
可以通过kill -l查看相应的信号
重要的几个信号
4.4 信号的默认动作
-
这里特别强调了 9) SIGKILL 和 19) SIGSTOP信号,不允许忽略和捕捉,只能执行默认动作。甚至不能将其设置为
4.5 信号产生函数
4.5.1 kill函数
-
其中,getpid() 获取当前的进程号。
kill(getpid(), sig);
-
例子:在父进程中杀死子进程
-
在父进程中,pid返回的是子进程中的pid值,即为0。
-
int pipefd[PIPE_SIZE] = {0};
pipe(pipefd);
pid_t pid = fork();
if (pid < 0)
{
}
else if (pid == 0)
{
/*子进程*/
while (1)
{
printf("i am child\n");
}
}
else
{
sleep(1);
/*父进程*/
int ret1 = kill(pid, SIGKILL);
if (ret1 == -1)
{
perror("kill error");
exit(-1);
}
/*回收资源*/
wait(NULL);
}
return 0;
4.5.2 raise函数
-
参数为指定信号
#include <stdio.h>
#include <string.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include<signal.h>
#include<stdlib.h>
int main()
{
int count = 15;
while(count--)
{
if(count == 5)
{
raise(SIGKILL);
}
printf("hello world\n");
sleep(1);
}
return 0;
}
运行结果:
4.5.3 abort函数
-
无参数,返回终止信号
int main()
{
int count = 5;
while(count--)
{
if(count == 2)
{
/*给自己发送终止信号*/
abort();
}
printf("hello world\n");
sleep(1);
}
return 0;
}
(5)信号捕捉
5.1 信号处理方式
-
一个进程收到一个信号的时候,可以用如下方法进行处理:
-
-
执行系统默认动作
-
对大多数信号来说,系统默认动作是用来终止该进程。
-
-
-
忽略此信号(丢弃)
-
接收到此信号后没有任何动作。
-
-
-
执行自定义信号处理函数(捕获)
-
用用户定义的信号处理函数处理该信号。
-
-
-
[注意] : SIGKILL和SIGSTOP不能更改信号的处理方式,因为它们向用户提供了一种使进程终止的可靠方法。
5.2 signal函数
-
signal函数是注册信号
-
kill是发送信号
-
举例:
-
要求1:不管我客户端做任何事情,服务器都不能宕机
-
要求2: Ctrl+C 回收资源, 不允许交由操作系统
-
因为 Ctrl+C是系统回收资源,为确保不宕机(崩了),因此可以使用信号接收Ctrl+C,然后实施回收操作。
-
-
通过注册一个信号,比如SIGIN,该信号接收Ctrl + C的指令。
-
因此按Ctrl + C,则返回SIGIN信号,传进signal函数,执行钩子函数。
-
代码段
int g_fd = 0;
/*钩子函数要求:函数命名一定要有Handler*/
void sigHandler(int num)
{
printf("tao wanbao num:%d\n", num);
close(g_fd);
exit(0);
}
int main()
{
// signal(SIGALRM, sigHandler);//自定义动作
// signal(SIGALRM, SIG_IGN);//忽略该信号
signal(SIGINT, sigHandler); // 自定义动作
const char *name = "./test.txt";
g_fd = open(name, O_RDWR | O_CREAT, 0644);
if (g_fd == -1)
{
perror("open error");
exit(-1);
}
char buffer[BUFFER_SIZE] = {0};
strcpy(buffer, "hello world\n");
int count = 10;
int writeBytes = 0;
while (count--)
{
writeBytes = write(g_fd, buffer, strlen(buffer) + 1);
printf("writeBytes: %d\n", writeBytes);
if(writeBytes < 0)
{
perror("write error");
exit(-1);
}
sleep(1);
}
close(g_fd);
return 0;
}
5.3 信号捕捉的原理
5.4 SIGCHLD信号
5.4.1 SIGCHLD信号产生条件
-
这个信号wait()也在使用,这样wait才能回收子进程的资源。
5.4.2 如何避免僵戶进程
-
最简单的方法,父进程通过wait()和waitpid()等函数等待子进程结束,但是,这会导致父进程挂起。
-
如果父进程要处理的事情很多,不能够挂起,通过signal() 函数人为处理信号SIGCHLD,只要有子进程退出自动调用指定好的回调函数,因为子进程结束后,父进程会收到该信号SIGCHLD,可以在其回调函数里调用wait()或waitpid()回收。
7、线程
(1)线程概念
-
在许多经典的操作系统教科书中,总是把进程定义为程序的执行实例,它并不执行什么,只是维护应用程序所需的各种资源,而线程则是真正的执行实体。
-
所以,线程是轻量级的进程(LWP: light weight process),在Linux环境下线程的本质仍是进程。
-
为了让进程完成一定的工作, 进程必须至少包含一个线程。
-
进程,直观点说,保存在硬盘上的程序运行以后,会在内存空间里形成一个独立的内存体, 这个内存体有自己的地址空间,有自己的堆,上级挂靠单位是操作系统。操作系统会以进程为单位,分配系统资源,所以我们也说,进程是CPU分配资源的最小单位。
-
线程存在与进程当中(进程可以认为是线程的容器),是操作系统任务调度执行的最小单位。说通俗点,线程就是干活的。进程是具有一定独立功能的程序关于某个数据集合上的一次运行活动,进程是系统进行资源分配和调度的一个独立单位。
-
线程是进程的一个实体,是CPU调度和分派的基本单位,它是比进程更小的能独立运行的基本单位。线程自己基本上不拥有系统资源,只拥有一点在运行中必不可少的资源(如程序计数器,-组寄存器和栈),但是它可与同属一个 进程的其他的线程共享进程所拥有的全部资源。
-
如果说进程是一个资源管家,负责从主人那里要资源的话,那么线程就是干活的苦力。一个管家必须完成一项工作,就需要最少一个苦力,也就是说,一个进程最少包含一个线程,也可以包含多个线程。苦力要干活,就需要依托于管家,所以说一个线程,必须属于某一个进程。
-
进程有自己的地址空间,线程使用进程的地址空间,也就是说,进程里的资源,线程都是有权访问的,比如说堆啊,栈啊,静态存储区什么的。
总结:线程是依托进程而存在;线程的创建也是基于进程的,如果只是单独的进程,该进程又被称为主进程/主线程。
(2)线程的特点
-
类Unix系统中,早期是没有"线程”概念的,80年代才引入,借助进程机制实现出了线程的概念。
-
因此在这类系统中,进程和线程关系密切:
-
线程是轻量级进程(light-weight process),也有PCB, 创建线程使用的底层函数和进程一样,都是clone。
-
从内核里看进程和线程是一样的,都有各自不同的PCB。
-
进程可以蜕变成线程。
-
在linux下,线程是最小的执行单位,进程是最小的分配资源单位。
-
(3)线程常用操作
3.1 线程号
-
就像每个进程都有一个进程号一样,每个线程也有一个线程号。进程号在整个系统中是唯一的,但线程号不同,线程号只在它所属的进程环境中有效。
-
进程号用pid_t数据类型表示,是一个非负整数。线程号则用pthread_t数据类型来表示(线程标识符(ID)),Linux 使用无符号长整数表示。
-
有的系统在实现pthread _t的时候,用一个结构体来表示,所以在可移植的操作系统实现不能把它做为整数处理。
3.1.2 pthread_self函数
-
获取线程号,返回的是%ld
3.2 线程的创建
3.2.1 phread_create函数
-
编译时需要加库-lpthread
-
第三个参数为函数的首地址,可不是回调函数。
-
看进程的信息是使用ps -ef
-
看线程的信息是使用gbd attach + 进程号
#include <pthread.h>
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
#include<stdlib.h>
void *thread_func()
{
printf("new thread:%ld\n", pthread_self());//子线程
}
int main()
{
pthread_t tid ;
int ret = pthread_create(&tid, NULL, thread_func, NULL);
if(ret != 0)
{
perror("pthread_create error\n");
exit(-1);
}
printf("main thread:%ld\n", pthread_self());//主线程
sleep(1);
return 0;
}
3.3 线程的资源回收
3.3.1 pthread_join函数
-
回收的是子线程的资源,同wait()差不多。
-
若主线程结束,子线程还没有结束就阻塞。
void *thread_func()
{
printf("new thread:%ld\n", pthread_self());
sleep(1);
/*线程结束*/
/*方式1*/
static int retStatus = -45;//static修饰的变量也会等程序结束才会清空该地址
/*方式二*/
//int *retStatus = (int *)malloc(sizeof(int)* 1);
*//retStatus = -45;
pthread_exit(&retStatus);
}
int main()
{
pthread_t tid ;
int ret = pthread_create(&tid, NULL, thread_func, NULL);
if(ret != 0)
{
perror("pthread_create error\n");
exit(-1);
}
printf("tid:%ld\n", tid);
printf("main thread:%ld\n", pthread_self());
/*阻塞等待回收子线程的资源*/
int *retStatus = NULL;
pthread_join(tid, (void *)&retStatus);
printf("retStatus: %d\n", *retStatus);
sleep(1);
return 0;
}
3.4 线程退出
在进程中我们可以调用exit函数或_exit函数来结束进程,在一个线程中我们可以通过以下三种在不终止整个进程的情况下停止它的控制流。
-
线程从执行函数中返回。
-
线程调用pthread_exit退出线程。
-
线程可以被同一进程中的其它线程取消。
3.4.1 pthread_exit函数
3.5 线程取消
-
注意:线程的取消并不是实时的,而又一定的延时。需要等待线程到达某个取消点(检查点)。类似于玩游戏存档,必须到达指定的场所(存档点,如:客栈、仓库、城里等)才能存储进度。
-
杀死线程也不是立刻就能完成,必须要到达取消点。
-
取消点:是线程检查是否被取消,并按请求进行动作的一个位置。通常是一些系统调用creat, open, pause,close, read, write.... 执行命令man 7 pthreads可以查看具备这些取消点的系统调用列表。可粗略认为一个系统调用(进入内核)即为一个取消点。
3.5.1 pthread_cancel函数
void *thread_func()
{
printf("new thread:%ld\n", pthread_self());
while(1)//让其死循环
{
printf("1 am thread...\n");
sleep(1);
}
/*线程结束*/
/*方式1*/
static int retStatus = -45;//static修饰的变量也会等程序结束才会清空该地址
/*方式二*/
//int *retStatus = (int *)malloc(sizeof(int)* 1);
//retStatus = -45;
pthread_exit(&retStatus);
}
int main()
{
pthread_t tid ;
int ret = pthread_create(&tid, NULL, thread_func, NULL);
if(ret != 0)
{
perror("pthread_create error\n");
exit(-1);
}
sleep(5);
/*杀死线程*/
printf("tid:%ld\n", tid);
pthread_cancel(tid);//杀死死循环子进程
printf("main thread:%ld\n", pthread_self());
/*阻塞等待回收子线程的资源*/
int *retStatus = NULL;
pthread_join(tid, (void *)&retStatus);
printf("retStatus: %d\n", *retStatus);
return 0;
}
3.6 线程分离
-
一般情况下, 线程终止后,其终止状态一直保留到其它线程调用pthread_join获取它的状态为止。但是线程也可以被置为detach状态, 这样的线程一旦终止就立刻回收它占用的所有资源,而不保留终止状态。
-
不能对一个已经处于detach状态的线程调用pthread_join, 这样的调用将返回EINVAL错误。也就是说,如果已经对一个线程调用了pthread_detach就不能再调用pthread_join了。
3.6.1 ptherad_detach函数
void *thread_func(void *arg)
{
pthread_detach(pthread_self());
printf("new thread:%ld\n", pthread_self());
while(1)
{
printf("1 am thread...\n");
sleep(1);
}
static int retStatus = -45;//static修饰的变量也会等程序结束才会清空该地址
pthread_exit(&retStatus);
}
int main()
{
pthread_t tid ;
int ret = pthread_create(&tid, NULL, thread_func, NULL);
if(ret != 0)
{
perror("pthread_create error\n");
exit(-1);
}
/*这边延时操作就是为了让子线程分离*/
sleep(1);
/*阻塞等待回收子线程的资源*/
int *retStatus = NULL;
ret = pthread_join(tid, (void *)&retStatus);
printf("ret: %d\n", ret);
if(ret == EINVAL)//接收EINVAL错误
{
printf("detached...\n");
}
int cnt = 5;
while(cnt--)
{
printf("main thread\n");
sleep(1);
}
if(retStatus)
{
/*解引用*/
printf("retStatus:%d\n", *retStatus);
}
return 0;
}
3.6.2 结论
-
创建一个线程后,就需要将其分离。
(4)进程与线程的区别
8、线程同步
-
线程同步是确保使用共同资源的线程(即定义为线程相关组的线程)以串行方式执行各自的代码,这意味着在一段时间内仅允许单个线程执行,从而消除多个线程对共享资源的并发访问冲突。简而言之,线程同步就是协同步调,按预定的先后次序进行运行。当有一个线程在对内存进行操作时,其他线程都不可以对这个内存地址进行操作,直到该线程完成操作。
-
线程资源是共享的。
-
线程不共享的只有各自栈空间,其他全部共享。
-
栈空间是在各自的子线程的函数中定义的参数,这是不共享的。
(1)资源竞争
-
资源共享一定会引发竞争
(2)互斥锁Mutex
-
因为共享会存在竞争,但是的需要一定的秩序才能保证资源的分配与运行效率最大化
-
在多任务操作系统中,同时运行的多个任务可能都需要使用同一种资源。这个过程有点类似于在公司部门里,我在使用着打印机打印东西的同时(还没有打印完),别人刚好也在此刻使用打印机打印东西,如果不做任何处理的话,打印出来的东西肯定是错乱的。
-
而在线程里也有这么一把锁:互斥锁(mutex) ,也叫互斥量,互斥锁是一种简单的加锁的方法来控制对共享资源的访问,互斥锁只有两种状态,即加锁( lock )和解锁( unlock )。
-
互斥锁的操作流程如下:
-
1、 在访问共享资源后临界区域前,对互斥锁进行加锁。
-
2 、在访问完成后释放互斥锁导上的锁。
-
3 、对互斥锁进行加锁后,任何其他试图再次对互斥锁加锁的线程将会被阻塞,直到锁被释放。
-
-
互斥锁的数据类型是: pthread_mutex_t
-
死锁:多个线程竞争资源造成了僵局
2.1 pthread_mutex_init函数
-
静态初始化:给锁结构体里面的参数赋值为0
-
先上锁,打印完在解锁
2.2 pthread_mutex_lock函数
-
当一个函数被一个进程调用后,肯定得阻止其他进程调用,则开始对这个函数内部上锁,阻止其他进程进来,直至解锁。
2.3 pthread_mutex_unlock函数
2.4 pthread_mutex_destroy函数
2.5 死锁
解决方法
-
死锁预防:
-
通过破坏死锁的四个必要条件之一,避免死锁的发生。例如,可以通过一次性分配所有需要的资源来破坏“占有并等待条件”,或 者允许资源被强制释放来破坏“不可剥夺条件”。
-
-
死锁避免:
-
通过对资源分配进行动态检查,确保系统不会进入死锁状态。例如,银行家算法是经典的死锁避免算法,它通过模拟资源分配的结果来决定是否允许资源分配,从而避免系统进入死锁状态。
-
-
死锁检测和恢复:
-
允许死锁发生,但通过监控系统检测死锁。一旦检测到死锁,系统会采取措施恢复,例如强制终止某些进程或回收资源来打破死锁。
-
-
死锁忽略:
-
这是最简单但也最冒险的策略,系统不采取任何措施防止或检测死锁,而是依赖用户或系统管理员来处理死锁。这种策略在某些系统中采用,如 UNIX 和 Windows,认为死锁发生概率较低,不值得为此付出额外的代价。
-
/**
* @brief 尝试锁定互斥锁
*
* 尝试在给定的时间范围内锁定指定的互斥锁。如果互斥锁已经被其他线程锁定,则该函数将等待指定的秒数,
* 并尝试重新锁定互斥锁,直到达到指定的尝试次数或成功锁定互斥锁。
*
* @param m 指向互斥锁的指针
* @param seconds 等待的秒数
* @param count 尝试锁定的次数
*
* @return 如果成功锁定互斥锁,则返回true;否则返回false
*/
bool MutexTryLock(Mutex * m, int seconds, int count)
{
while(count != 0)
{
//上锁成功
if(pthread_mutex_trylock(&m->mutex) == 0)
{
return true;
}
else
{
//等待seconds秒
sleep(seconds);
count--;
}
}
return false;
}
2.6 活锁
活锁(Livelock)是多线程编程中一种特殊的并发问题,它与死锁(Deadlock)类似,但有着不同的表现形式。活锁指的是多个线程或进程在响应对方的动作时不断地改变自己的状态,从而使得这些线程或进程都无法继续向前执行。虽然系统在运行,但实际上没有线程能够继续完成任务。
活锁的特点
-
状态不断变化:
-
在活锁的情况下,线程不会停滞不前(如死锁中的等待),而是不断地进行一些动作,但这些动作并不能使它们接近完成任务的目标。也就是说,线程虽然是“活着”的,但却在原地打转。
-
-
没有线程被阻塞:
-
与死锁不同,活锁中的线程都不是被阻塞的状态,它们能够继续运行,但由于互相干扰或不断地改变状态,导致没有线程能够取得进展。
-
-
可能自我解除:
-
在某些情况下,活锁可能会自动解除。如果系统中的线程能够随机地选择不同的行动路径,活锁可能在一定时间内自行解决。
-
活锁的示例
一个典型的活锁例子是两个线程试图进入对方正在试图离开的区域。例如:
-
线程A和线程B都试图避免碰撞,于是A后退一步,B也后退一步。接着,A再前进一步,B也前进一步。
-
由于两者都在试图避免冲突,结果它们会不断地移动而无法进入预期的区域,从而陷入了活锁。
处理活锁的方法
-
随机化:
-
引入随机性,让线程在重试之前等待一个随机时间段,以避免所有线程都在同一时间采取相同的行动。
-
-
优先级机制:
-
为线程分配不同的优先级,让优先级高的线程先行,以避免相互等待的循环。
-
-
限制重试次数:
-
限制线程重试某一操作的次数,超过次数后强制线程采取其他策略或进入等待状态。
-
活锁是一种类似于死锁的问题,但线程并未被阻塞,而是陷入了无效的重复动作中。理解和预防活锁对于构建高效且健壮的并发系统非常重要。通过引入随机性、优先级或限制重试次数等策略,可以有效地避免或解决活锁问题。
2.7 代码段
-
fflush(stdout);
的常见用途包括:-
确保在程序崩溃或异常终止之前,所有待输出的内容都被写入到目标设备。
-
在某些交互式应用中,需要立即看到输出结果,而不是等待缓冲区满或程序结束。
-
当输出被重定向到文件或其他非交互式设备时,确保数据及时写入。
-
-
fflush(stdout); 确保 "字符, " 立即输出到屏幕
#include <stdio.h>
#include <pthread.h>
#include <stdlib.h>
#include <unistd.h>
/* 面包 */
int g_data = 10;
/* 锁 🔒*/
pthread_mutex_t g_mutex;
pthread_cont_t g_cond;
#if 0
void * thread_func1(void *arg)
{
while (g_data > 0)
{
/* 客气一下 */
usleep(10);
g_data--;
printf("thread1... gdata:%d\n", g_data);
}
}
void * thread_func2(void *arg)
{
while (g_data > 0)
{
/* 假装客气一下 */
usleep(5);
g_data--;
printf("thread2... gdata:%d\n", g_data);
}
}
void * thread_func3(void *arg)
{
while (g_data > 0)
{
/* 假装客气一下 */
usleep(7);
g_data--;
printf("thread3... gdata:%d\n", g_data);
}
}
#endif
// 打印机,公共资源
void printer(char *str)
{
pthread_mutex_lock(&g_mutex);
while (*str != '\0')
{
putchar(*str);//使用 putchar 函数时,你可以直接传递一个字符常量或者一个字符变量的值
fflush(stdout);
str++;
sleep(1);
}
pthread_mutex_unlock(&g_mutex);
printf("\n");
}
// 线程一
void *thread_func1(void *arg)
{
char *str = "hello";
printer(str); //打印
}
// 线程二
void *thread_func2(void *arg)
{
char *str = "world";
printer(str); //打印
}
int main()
{
/* 初始化锁 */
pthread_mutex_init(&g_mutex, NULL);
pthread_t tid1, tid2;
int ret = pthread_create(&tid1, NULL, thread_func1, NULL);
if (ret == -1)
{
printf("pthread_create error\n");
exit(-1);
}
ret = pthread_create(&tid2, NULL, thread_func2, NULL);
if (ret == -1)
{
printf("pthread_create error\n");
exit(-1);
}
while(1)
{
sleep(1);
}
pthread_mutex_destroy(&g_mutex);
return 0;
}
9、条件变量
(1)条件变量概述
-
当同时有多个进程抢夺一个资源时,其中多个进程的执行顺序是根据内部的调度算法去执行,谁的执行速度快,谁就去先占领资源,但是同时慢的线程根本占领不了,一直被执行速度快的线程给占用,因此引入条件变量。
-
与互斥锁不同,条件变量是用来等待而不是用来上锁的,条件变量本身不是锁!条件变量用来自动阻塞一个线程,直到某特殊情况发生为止。通常条件变量和互斥锁同时使用。
-
条件变量的两个动作:
-
条件不满,阻塞线程
-
当条件满足,通知阻塞的线程开始工作
-
-
条件变量的类型: pthread_cond_t。
(2) pthread_cond_init函数
(3) pthread_cond_wait函数
-
该函数不消耗任何的CPU时间
-
pthread_cond_wait
是一个 POSIX 线程(pthreads)库中的函数,用于线程同步。这个函数允许一个线程等待某个条件变量的满足。它通常与互斥锁(mutex)一起使用,以确保对共享数据的正确访问。 -
需要注意的是,调用
pthread_cond_wait
的线程必须已经锁定了mutex
指定的互斥锁。在调用pthread_cond_wait
后,线程会释放这个互斥锁,并进入等待状态。当线程被唤醒时,它会重新获取这个互斥锁。 -
另外,还需要注意的是,条件变量和互斥锁必须在使用前进行初始化,且在不再需要时进行销毁。
-
该函数启动时,会阻塞住,等收到参数中的信号在解除阻塞和解锁,随后再获取互斥锁
(4) pthread_cond_destroy函数
(5) pthread_cond_signal函数
(6)pthread_cond_broadcast函数
-
pthread_cond_broadcast
函数是 POSIX 线程库中的一个用于条件变量的函数。它用于唤醒所有等待在特定条件变量上的线程。这在需要多个线程响应某个事件时非常有用。 -
函数原型
-
#include <pthread.h> int pthread_cond_broadcast(pthread_cond_t *cond);
-
-
参数
-
cond
: 指向要广播的条件变量的指针。
-
-
返回值
-
成功时返回
0
。 -
失败时返回错误码。
-
(7)消费者和生产者消费条件变量模型
-
线程同步典型的案例即为生产者消费者模型,而借助条件变量来实现这一模型,是比较常见的一种方法。
-
假定有两个线程,一个模拟生产者行为,一个模拟消费者行为。两个线程同时操作一个共享资源(一般称之为汇聚),生产向其中添加产品,消费者从中消费掉产品。
/* 面包 */
int g_data = 10;
/* 锁 资源 */
pthread_mutex_t g_mutex;
/* 条件变量 资源 */
pthread_cond_t g_freePlate;
pthread_cond_t g_enoughPie;
/* 链表 */
typedef struct Node
{
int data;
struct Node * next;
}Node;
/* 链表头结点 */
Node * head = NULL;
int g_pieCnt = 0;
/* 常量 */
const int maxPlates = 8;
/* 生产者 : 做饼 */
void * thread_produce(void *arg)
{
while (1)
{
pthread_mutex_lock(&g_mutex);
/* 当前饼的数量 已经等于盘子的数量 */
while (g_pieCnt == maxPlates)
{
pthread_cond_wait(&g_freePlate, &g_mutex);
}
/* 程序到这个地方 : 有空闲的盘子了 */
/* 创建一个链表结点 (饼) */
Node * newPie = malloc(sizeof(Node) * 1);
newPie->data = rand() % 999 + 1; // 1 ~ 999
/* 头插 */
newPie->next = head;
head = newPie;
printf("producer make %d pie\n", newPie->data);
/* 当前已经有的饼加一. */
g_pieCnt++;
pthread_mutex_unlock(&g_mutex);
pthread_cond_signal(&g_enoughPie);
/* 休息1S */
sleep(1);
}
}
/* 消费者 : 吃饼 */
void * thread_consume(void *arg)
{
while (1)
{
pthread_mutex_lock(&g_mutex);
while (g_pieCnt == 0)
{
pthread_cond_wait(&g_enoughPie, &g_mutex);
}
/* 程序到这个地方说明: 有饼吃 */
Node * pie = head;
head = head->next;
printf("tid:%ld,\tconsumer eat %d pie\n", pthread_self(), pie->data);
/* 释放内存 */
if (pie != NULL)
{
free(pie);
pie = NULL;
}
/* 饼减一. */
g_pieCnt--;
pthread_mutex_unlock(&g_mutex);
pthread_cond_signal(&g_freePlate);
}
}
int main()
{
/* 初始化锁 */
pthread_mutex_init(&g_mutex, NULL);
/* 初始化条件变量 */
pthread_cond_init(&g_freePlate, NULL);
pthread_cond_init(&g_enoughPie, NULL);
pthread_t tid1, tid2, tid3;
int ret = pthread_create(&tid1, NULL, thread_produce, NULL);
if (ret == -1)
{
printf("pthread_create error\n");
exit(-1);
}
ret = pthread_create(&tid2, NULL, thread_consume, NULL);
if (ret == -1)
{
printf("pthread_create error\n");
exit(-1);
}
ret = pthread_create(&tid3, NULL, thread_consume, NULL);
if (ret == -1)
{
printf("pthread_create error\n");
exit(-1);
}
while(1)
{
sleep(1);
}
return 0;
}
10、进/线程高并发
在高并发环境下,线程和进程的使用都涉及到如何高效地管理资源、保证性能,并避免潜在的问题。让我们先区分一下线程和进程的概念,然后讨论它们在高并发中的应用和可能出现的问题。
1. 线程和进程的区别
-
进程:
-
进程是操作系统中资源分配的基本单位。每个进程都有自己独立的内存空间、文件描述符等资源。
-
进程之间的通信较为复杂,需要使用进程间通信(IPC)机制,如管道、信号、共享内存等。
-
进程切换(上下文切换)开销较大,因为需要保存和恢复所有进程的状态。
-
-
线程:
-
线程是进程中的一个执行单元,线程共享进程的内存空间和资源,但有自己独立的栈、寄存器等。
-
线程之间通信相对简单,因为它们共享内存,可以直接读写共享数据。
-
线程切换开销比进程小,但仍然存在上下文切换的开销。
-
2. 线程和进程的高并发
高并发下的线程使用:
-
多线程并发:多个线程在同一个进程中并行执行任务,适用于I/O密集型和需要频繁数据共享的任务。
-
优点:线程切换开销小,资源共享方便。
-
常见问题:
-
线程安全:多个线程同时读写共享数据时,容易出现竞态条件(Race Condition),导致数据不一致或崩溃。需要使用锁(如互斥锁、读写锁)来同步线程,但锁的使用不当会引起死锁、饥饿、活锁等问题。
-
线程上下文切换:线程切换频繁时,会增加系统开销,降低性能。
-
线程的上下文切换指的是操作系统将 CPU 从一个线程切换到另一个线程的过程。这是多任务操作系统用来实现并发执行的一个关键机制。在上下文切换期间,操作系统会保存当前正在执行的线程的状态(称为“上下文”),然后加载并恢复另一个线程的状态,使其能够继续执行。
-
-
高并发下的进程使用:
-
多进程并发:多个进程并行执行任务,各进程独立运行,适用于CPU密集型任务或需要严格隔离的任务。
-
优点:进程隔离性好,一个进程的崩溃不会影响其他进程。
-
常见问题:
-
进程间通信复杂:由于进程间不共享内存,数据交换需要通过IPC机制,增加了开发复杂度和延迟。
-
进程上下文切换开销大:频繁的进程切换会导致性能下降,尤其是在高并发环境下。
-
3. 高并发中常见问题
-
资源竞争:线程或进程可能会争夺系统资源(如CPU、内存、文件句柄),导致性能下降甚至系统崩溃。
-
死锁:多个线程或进程因资源竞争进入一种相互等待的状态,导致程序无法继续执行。
-
上下文切换开销:频繁的上下文切换会增加系统负担,降低整体性能。
-
内存泄漏:不当的资源管理会导致内存泄漏,长时间运行后可能耗尽系统内存。
-
过度线程或进程创建:如果高并发环境下创建过多的线程或进程,系统可能会耗尽资源,反而导致性能下降。
4. 解决策略
-
使用线程池或进程池:限制线程或进程的数量,避免系统资源被耗尽。
-
优化锁机制:使用细粒度锁或无锁数据结构,减少锁的争用和死锁的风险。
-
异步与事件驱动模型:对于I/O密集型任务,使用异步或事件驱动的架构,减少线程/进程的上下文切换。
计算机网络
1、网络基础及概念
(1)IP(Internet Protocol)
1.1 概念
3.2 数据链路层 - 封装成帧
3.3 数据链路层 - 透明传输
3.4 数据链路层 - 差错检验
3.5 Ethernet V2 帧的格式
3.6 交换机
3.7 网卡
(4)三层:网络层
4.1 网络层首部 - 版本、首部长度、区分服务
4.2 网络层首部 - 总长度
4.3 网络层首部 - 标识、标志
4.4 ping
4.5 网络层首部 - 片偏移
4.6 网络层首部 - 生存时间
4.7 网络层首部 - 协议、首部校验和
-
一层->物理层
-
双绞线,光纤等
-
-
二层->数据链路层
-
交换机
-
网卡MAC地址
-
-
三层->网络层
-
IP地址
-
-
四层->传输层
-
TCP/UDP
-
-
五层->应用层
-
OSI 七层网络模型和 Linux 四层网络模型是用于描述和管理网络协议的不同层次的模型,但它们关注的层次和细节有所不同:
-
OSI 七层模型:
-
目的是:提供一个详细的网络通信分层架构,帮助理解和设计网络协议。
-
层次:
-
物理层:传输原始比特流。
-
数据链路层:提供节点到节点的可靠传输。
-
网络层:路由和转发数据包。
-
传输层:提供端到端的通信服务。
-
会话层:管理会话控制。
-
表示层:数据格式转换。
-
应用层:网络应用和服务接口。
-
-
-
Linux 四层模型:
-
目的是:描述操作系统内核处理网络通信的实际过程,更贴近实际实现。
-
层次:
-
网络接口层(Network Interface Layer):处理数据链路层和物理层的功能。
-
网络层(Network Layer):处理 IP 协议等网络层功能。
-
传输层(Transport Layer):处理 TCP 和 UDP 协议。
-
应用层(Application Layer):处理高层协议和应用的接口。
-
-
-
IP地址分为两部分
-
主机位
-
网络位
-
-
其中,IP 位与 子网掩码 = 主机位
-
主要区别:
-
OSI 模型 是理论上的模型,用于教育和理解网络协议的层次化设计。
-
Linux 四层模型 更关注于操作系统内核中实际的网络协议栈实现,更简化,直接对应实际的协议处理流程。
-
本质是一个整形数,用于表示计算机在网络中的地址。IP协议版本有两个:IPv4和IPv6.
-
IPv4(Internet Protocol version4):
-
使用一个32位的整形数描述一个IP地址。
-
也可以使用一个点分十进制字符串描述这个IP地址:
192.168.247.135
. 分成了4份, 每份1字节。最小值为0,最大值为 255。 -
那么
0.0.0.0
是最小的IP地址,255.255.255.255
是最大的IP地址。 -
按照IPv4协议计算,可以使用的IP地址共有 $2^{32}$个。
-
-
IPv6(Internet Protocol version6):
-
使用一个128位的整形数描述一个IP地址,16个字节
-
也可以使用一个字符串描述这个IP地址:
2001:0db8:3c4d:0015:0000:0000:1a2f:1a2b
分成了8份,每份2字节。每一部分以16进制的方式表示 -
按照IPv6协议计算,可以使用的IP地址共有 $2^{128}$ 个
-
-
查看IP地址命令:
-
linux系统:
ifconfig
-
windows系统:
ipconfig
-
-
1.2 ip分类
IP地址根据其范围和用途分为五类:A类、B类、C类、D类和E类。每一类具有不同的网络规模和用途。以下是对每一类的详细介绍:
-
A类 IP 地址
-
范围:1.0.0.0 到 126.0.0.0
-
子网掩码:255.0.0.0
-
网络号位数:8位
-
主机号位数:24位
-
最大网络数量:128个(其中0和127为保留地址)
-
每个网络的最大主机数量:16,777,214个
-
特点:
-
A类地址用于非常大的网络,例如跨国公司的内部网络。
-
网络号的第一个字节用于定义网络,剩下的三个字节用于定义主机。
-
-
-
B类 IP 地址
-
范围:128.0.0.0 到 191.255.0.0
-
子网掩码:255.255.0.0
-
网络号位数:16位
-
主机号位数:16位
-
最大网络数量:16,384个
-
每个网络的最大主机数量:65,534个
-
特点:
-
B类地址用于中等规模的网络,例如大学和大公司的网络。
-
网络号由前两个字节定义,后两个字节用于主机。
-
-
-
C类 IP 地址
-
范围:192.0.0.0 到 223.255.255.0
-
子网掩码:255.255.255.0
-
网络号位数:24位
-
主机号位数:8位
-
最大网络数量:2,097,152个
-
每个网络的最大主机数量:254个
-
特点:
-
C类地址用于小型网络,例如小型企业的内部网络。
-
网络号由前三个字节定义,最后一个字节用于主机。
-
-
-
D类 IP 地址
-
范围:224.0.0.0 到 239.255.255.255
-
用途:多播
-
特点:
-
D类地址用于多播,数据同时发送到多个计算机。
-
没有子网掩码。
-
-
-
E类 IP 地址
-
范围:240.0.0.0 到 255.255.255.255
-
用途:研究和实验
-
特点:
-
E类地址保留用于实验和未来用途。
-
不用于常规网络通信。
-
-
-
特殊IP地址
-
127.0.0.0到127.255.255.255:用于回环测试,本地环回地址(例如127.0.0.1)用于测试网络接口。
-
0.0.0.0:用于指示未知地址或默认路由。
-
255.255.255.255:用于广播到本地网络。
-
-
总结
-
IP地址分类帮助网络管理员根据规模和需求选择合适的地址块,并在网络设计中有效地管理IP地址空间。使用CIDR(无类别域间路由)可以进一步灵活地分配IP地址,超越传统分类限制。
-
-
1.3 网络掩码
-
网络掩码(Subnet Mask)是一个32位的地址,用于区分IP地址的网络部分和主机部分。它帮助确定一个IP地址属于哪个子网,并在IP网络中用于划分子网、控制流量和管理地址空间。
-
网络掩码的基本概念
-
IP地址
-
IP地址是一个32位的二进制数,通常表示为四个十进制数,每个数用点分隔(例如:192.168.1.1)。
-
IP地址分为两个部分:网络部分和主机部分。
-
-
子网掩码
-
子网掩码也是一个32位的二进制数,与IP地址结合使用。
-
它的网络部分用连续的1表示,主机部分用连续的0表示(例如:255.255.255.0)。
-
-
CIDR表示法
-
子网掩码可以用CIDR(无类别域间路由)表示法表示,即在IP地址后面加斜杠和一个数字,该数字表示网络部分的位数(例如:192.168.1.1/24)。
-
-
-
网络掩码的作用
-
确定子网
-
子网掩码确定网络的范围和划分子网的大小。
-
根据子网掩码,IP地址的网络部分用于识别子网,主机部分用于识别子网中的设备。
-
-
流量路由
-
路由器使用子网掩码来确定数据包的目的地是本地子网还是需要转发到其他网络。
-
路由器比较目的IP地址和子网掩码,以决定数据包的转发路径。
-
-
IP地址分配
-
网络管理员使用子网掩码来优化IP地址的分配,减少浪费,并提高网络效率。
-
-
-
子网掩码示例
-
标准子网掩码
-
/24:255.255.255.0
-
网络部分:24位,主机部分:8位
-
常用于小型局域网(最多支持254个主机)
-
-
/16:255.255.0.0
-
网络部分:16位,主机部分:16位
-
常用于中型网络(最多支持65,534个主机)
-
-
/8:255.0.0.0
-
网络部分:8位,主机部分:24位
-
常用于大型网络(最多支持16,777,214个主机)
-
-
-
计算示例
-
IP地址:192.168.1.10
-
子网掩码:255.255.255.0 (/24)
-
网络地址计算:
-
IP地址和子网掩码按位与运算
-
结果为网络地址:192.168.1.0
-
-
广播地址计算:
-
网络地址主机部分全为1
-
结果为广播地址:192.168.1.255
-
-
-
-
子网划分与优化
-
子网划分
-
根据网络规模和需求,将网络划分为多个子网。
-
选择合适的子网掩码,以确保地址空间利用效率最大化。
-
-
子网优化
-
使用变长子网掩码(VLSM)以灵活地分配不同大小的子网。
-
减少地址浪费,提高地址使用效率。
-
-
-
总结
-
网络掩码在IP网络中扮演关键角色,它定义了网络结构、管理IP地址分配,并协助路由器进行数据包转发。理解子网掩码的功能和应用有助于网络设计和维护。
-
-
1.4 ifconfig显示的信息
-
ifconfig
是一个在 Unix 和类 Unix 系统(如 Linux)中用于配置网络接口的命令。当你执行ifconfig
时,它会显示关于系统上所有活动网络接口的信息。这些信息通常包括:-
接口名称:例如
eth0
、wlan0
、lo
等,分别代表以太网接口、无线接口和本地回环接口。 -
接口状态:接口是否是
UP
或DOWN
。如果接口是UP
,则表示它已启用并可以发送和接收数据。 -
IP 地址:分配给接口的 IPv4 地址。
-
广播地址:用于在本地网络上进行广播的 IPv4 地址。
-
子网掩码:用于确定哪些 IP 地址位于同一本地网络上的掩码。
-
硬件地址(或 MAC 地址):网络接口的物理地址,用于在网络上进行唯一标识。
-
MTU(最大传输单元):接口可以处理的最大数据包大小。
-
RX packets:接口接收的数据包数量。
-
TX packets:接口发送的数据包数量。
-
RX errors 和 TX errors:接口在接收和发送数据时遇到的错误数量。
-
RX dropped 和 TX dropped:接口由于各种原因(如缓冲区溢出)而丢弃的数据包数量。
-
RX overruns 和 TX overruns:由于接收或发送队列溢出而丢弃的数据包数量。
-
碰撞:在以太网接口上发生的碰撞次数(如果有的话)。
-
中断:网络接口使用的硬件中断号(如果适用)。
-
-
(2)MAC地址
-
MAC地址:每张网卡设备都会一个或者多个网口。每个网口都会有单独且唯一的。
-
只有后3bytes是自己分配的。
-
(3)二层:数据链路层
3.1 概念
-
-
最大传输单元mtu
-
ARP协议用于获取对方的MAC地址。
-
request请求报文采用的是广播(目的MAC地址,FF:FF:FF:FF:FF:FF).
-
response回复报文采用的是单播回复。
-
-
ARP(Address Resolution Protocol,地址解析协议)是用来将IP地址解析为MAC地址的协议。主机或三层网络设备上会维护一 张ARP表,用于存储IP地址和MAC地址的映射关系,一般ARP表项包括动态ARP表项和静态ARP表项。
-
查看arp表的命令(command)是arp -a
-
删除arp表的命令(command)是arp -d
-
交换机里面存储的是来自各个PC端的MAC地址。
-
交换机的基本工作原理
-
学习MAC地址
-
当交换机接收到来自某个端口的数据帧时,它会检查该帧的源MAC地址和端口号,并将其记录在自己的MAC地址表(或称为转发表)中。
-
这样,交换机可以知道哪些MAC地址是连接到哪些端口上的。
-
-
构建转发表
-
交换机会持续更新其转发表,以反映网络设备的连接情况。
-
如果交换机接收到一个数据帧,其目的MAC地址尚未在转发表中记录,交换机将会广播该帧到所有端口(除了接收到该帧的端口),这称为“泛洪”。
-
-
数据帧转发
-
交换机会根据转发表来决定如何处理数据帧。
-
如果目的MAC地址在表中有记录,交换机会直接将数据帧转发到对应的端口。
-
如果没有记录(例如第一次通信),交换机会将帧发送到所有其他端口(泛洪)。
-
-
过滤和隔离冲突域
-
交换机能够隔离冲突域,即每个端口都是一个独立的冲突域,从而减少网络冲突。
-
交换机通过只将数据帧发送到目的设备所在的端口,从而减少不必要的网络流量。
-
-
全双工通信
-
大多数现代交换机支持全双工模式,即同时进行数据的发送和接收,提高了网络带宽和效率。
-
-
-
作用:抓包
-
源IP地址
-
目标IP地址
-
-
4.8 路由器
路由是指在计算机网络中确定数据包传输路径的过程。路由通常由专门的网络设备称为路由器(Router)来执行。路由器负责将数据从源设备传送到目标设备,跨越一个或多个网络。
-
路由的基本概念
-
路由器
-
路由器是连接多个网络的设备,它根据目标IP地址将数据包转发到合适的网络路径。
-
路由器在网络层(OSI模型的第三层)工作,能够管理和转发IP数据包。
-
-
路由表
-
路由器维护一个路由表,其中包含网络路径的信息,用于决定数据包应该转发到哪个下一跳(Next Hop)设备。
-
路由表条目通常包括目的网络、下一跳地址、网络掩码和接口信息。
-
-
路由协议
-
路由协议用于在路由器之间交换网络可达性信息,并动态更新路由表。
-
常见的路由协议包括RIP(Routing Information Protocol)、OSPF(Open Shortest Path First)、BGP(Border Gateway Protocol)等。
-
-
-
路由的基本过程
-
数据包接收
-
路由器从一个接口接收到数据包,并检查其目标IP地址。
-
-
查找路由表
-
路由器查找路由表,以确定数据包的最佳传输路径。
-
路由器选择具有最长匹配前缀的路由条目。
-
-
转发数据包
-
路由器根据路由表的信息,将数据包转发到下一跳设备。
-
如果数据包到达目标网络,路由器将其发送到最终目的设备。
-
-
动态更新
-
路由器使用路由协议与其他路由器交换信息,更新路由表以反映网络拓扑的变化。
-
-
-
路由的类型
-
静态路由
-
管理员手动配置路由表条目,适用于简单和小型网络。
-
优点是配置简单、无协议开销,但缺点是不具备自动适应网络变化的能力。
-
-
动态路由
-
使用路由协议自动学习和更新路由信息。
-
适用于大型和复杂网络,能够自动调整路由以适应网络拓扑变化。
-
-
-
路由协议的分类
-
距离矢量路由协议
-
基于路由跳数或距离来选择路径,如RIP。
-
优点是简单,缺点是在大型网络中可能导致路由不稳定。
-
-
链路状态路由协议
-
每个路由器都有完整的网络拓扑信息,如OSPF。
-
优点是快速收敛,适合大型网络,但需要更多的计算和内存资源。
-
-
路径矢量路由协议
-
使用路径属性来决定最佳路径,如BGP。
-
适用于互联网级别的路由,能够处理复杂的网络策略和路由选择。
-
-
-
路由的应用场景
-
企业网络:通过路由器连接不同的分支网络,实现数据通信。
-
互联网:ISP使用路由器和BGP协议连接全球网络。
-
数据中心:内部和外部网络之间的数据交换。
-
-
路由器的转发过程
-
路由器首先在路由表中查找,判明是否知道如何将分组信息发送到下一个站点(路由器或主机),如果路由器不知道,通常将该分组丢弃;否则就根据路由表的相应表项将分组发送到下一个站点。
-
路由表就是图中的iptables
-
-
路由器必须具备的基本条件包括
-
两个或两个以上的接口;
-
协议至少实现到网络层;
-
至少支持两种以上的子网协议;
-
具有存储、转发、寻径功能;
-
一组路由协议。
-
4.9 交换机和路由的区别
-
关键区别
-
功能:
-
交换机主要用于设备之间的通信,负责在同一网络或子网内传递数据。
-
路由器负责不同网络之间的数据传输和路由选择。
-
-
工作方式:
-
交换机使用MAC地址进行数据转发。
-
路由器使用IP地址和路由表进行数据转发。
-
-
应用场景:
-
交换机适用于局域网内部设备的快速连接。
-
路由器适用于连接不同的网络,例如将家庭网络连接到互联网。
-
-
配置和管理:
-
交换机通常不需要复杂的配置。
-
路由器需要配置IP地址、路由协议等参数,并支持更复杂的网络管理功能。
-
-
4.10 广播地址
在IP网络中,广播地址用于将数据包发送到同一网络中的所有主机。广播是一种通信方式,它允许网络上的所有设备接收到发送的数据包,而不需要逐个指定每个目标设备的IP地址。
-
广播地址的作用
-
网络发现和管理:
-
广播地址常用于网络发现和管理任务。例如,当一个设备加入网络时,它可能会通过广播地址发送一个请求,询问网络中的其他设备是否存在或可以提供特定服务(如DHCP请求)。
-
-
服务发现:
-
一些网络服务,如文件共享和打印服务,会使用广播地址来宣传它们的存在,让网络上的其他设备能够找到它们。
-
-
ARP(地址解析协议):
-
在以太网中,ARP协议用于将IP地址映射到MAC地址。为了找到特定IP地址对应的MAC地址,设备会发送一个ARP请求到广播地址,以便网络上的所有设备都能看到这个请求并进行回应。
-
-
-
广播地址的计算
-
在IPv4中,广播地址是通过将网络地址中的主机部分全部设置为1来计算的。例如:
-
假设网络地址为
192.168.1.0/24
,这表示子网掩码是255.255.255.0
。 -
在这个网络中,广播地址是
192.168.1.255
。这是因为在192.168.1.0
网络地址中,将主机部分(即最后一个字节)全设置为1,即255
。 -
在IPv6中,广播已被弃用,取而代之的是组播(Multicast)。IPv6使用组播地址来实现类似的功能,但并不使用传统的广播地址。
-
-
广播的局限性
-
网络拥堵:广播会增加网络流量,因为所有广播数据包都会被网络中的所有设备接收到。这可能导致网络拥堵和性能下降,尤其是在大型网络中。
-
安全隐患:广播可能会被用于攻击或恶意活动,如网络嗅探和伪装攻击。
-
(5)四层:传输层
-
资源就是带宽
5.1 UDP-数据格式
5.2 UDP-检验和
5.3 端口(port)
-
端口的作用是定位到主机上的某一个进程,通过这个端口进程就可以接受到对应的网络数据了。
-
比如: 在电脑上运行了微信和QQ, 小明通过客户端给我的的微信发消息, 电脑上的微信就收到了消息, 为什么?
-
因为:运行在电脑上的微信和QQ都绑定了不同的端口。通过IP地址可以定位到某一台主机,通过端口就可以定位到主机上的某一个进程通过指定的IP和端口,发送数据的时候对端就能接受到数据了。
-
-
端口也是一个整形数 unsigned short ,一个16位整形数,有效端口的取值范围是:0 ~ 65535(0 ~ 2^16-1)
-
提问:计算机中所有的进程都需要关联一个端口吗,一个端口可以被重复使用吗?
-
不需要,如果这个进程不需要网络通信,那么这个进程就不需要绑定端口的
-
-
一个端口只能给某一个进程使用,多个进程不能同时使用同一个端口
-
IP是区分主机,端口是区分软件和进程等。
-
netstat -h可以解决任何的网络问题
5.4 TCP-数据格式
5.5 TCP-检验和
5.6 TCP-标志位
5.7 TCP-序号、确认号、窗口
5.8 TCP-要点
5.8.1 可靠传输
-
三次握手
-
用超时重传的机制保证可靠传输
原文地址:https://blog.csdn.net/AVICCI/article/details/142532044
免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!