2024年7月

本文介绍基于
Python

ArcPy
模块,基于
矢量数据
范围,对大量
栅格遥感影像
加以
批量裁剪掩膜
的方法。

首先,话不多说,本文所需要的代码如下所示。

# -*- coding: utf-8 -*-
"""
Created on Tue Dec 13 20:07:48 2022

@author: fkxxgis
"""

import arcpy
from arcpy.sa import *

tif_file_path = "E:/AllYear/Original/"
clip_file_path = "E:/AllYear/Clip/"
shp_file_name = "E:/AllYear/Clip.shp"
arcpy.env.workspace = tif_file_path

tif_file_name = arcpy.ListRasters("*", "tif")

for tif_file in tif_file_name:
    key_name = tif_file.split(".tif")[0] + "_C.tif"
    clip_file_name = clip_file_path + key_name
    clip_file = ExtractByMask(tif_file, shp_file_name)
    clip_file.save(clip_file_name)

其中,
tif_file_path
表示待裁剪栅格文件的保存路径,
clip_file_path
表示裁剪后栅格文件的保存路径,
shp_file_name
表示裁剪时所需依据的空间范围矢量文件。

代码整体思路也很简单:首先,我们基于
arcpy.ListRasters()
函数,获取
tif_file_path
路径下原有的全部
.tif
格式的图像文件,并以列表的形式存放于
tif_file_name
中;随后,逐一取出
tif_file_name
列表中的栅格文件,进行裁剪处理。其中,因为是批量操作,所以需要对每一个输出的裁剪后栅格文件加以分别命名;我们就先通过字符串截取的方式,将原有栅格文件名称的
.tif
后缀前的全部内容保留,并在其后添加一个字段
_C
,表示是裁剪后的栅格文件,并将其作为裁剪后栅格文件各自的名称。随后,通过
ExtractByMask()
函数,基于矢量数据,对栅格文件加以裁剪,并最终通过
.save()
函数加以保存。

通过上述代码,我们即可在
clip_file_path
路径中看到批量裁剪后的栅格遥感影像文件。

这里需要注意,由于我们用到了
ArcPy
模块,因此如果大家的
Python
版本是
3.0
及以上,则需要在
ArcMap
软件中的
Python运行框
,或其对应的
IDLE
(如下图所示)中运行上述代码。

image

至此,大功告成。

关于学习.NET的历程回顾

自从2023年9月11日注册公众号以来,这次还是第一次介绍自己。我今年24岁,双非本,211硕,非计算机相关专业。大学期间接触过计算机相关的课程可能就《大学生计算机基础》、《C语言程序设计》,并且也没掌握多好。

22年4月研究生复试结束,联系好导师后,由于导师研究方向的缘故,才第一次接触到.NET。

image-20240723103636318

22年9月研究生开学,正式开始学习.NET,当时为了学习VB.NET,买了一本《VB.NET程序设计与软件项目实训》。

开始看《Visual Basic.NET实用教程》。

当时使用的还是.NET Framework+VB.NET+DevExpress+SqlServer+Winform组合,如果没有自己探索,可能研究生三年都会一直这样使用。

经过自己的探索了解,发现2016年就已经有.NET Core可以跨平台还更加强大,而且在.NET生态中,主流语言也不是VB.NET而是C#。

开始尝试学习C#,在力扣上使用C#做难度为简单的题,虽然那时候对我而言并不简单。

image-20240723104248626

这时期主要还是在学习.NET Framework+VB.NET+DevExpress+SqlServer+Winform组合。

有空的时候开始看一些有关C#的电子书,比如《深入理解C#》、《C# 7.0本质论》、《C#入门经典》、《C#图解教程》。

image-20240723110630180

主要是阅读《C#图解教程》与《C#入门经典》,《深入理解C#》与《C# 7.0本质论》没有看下去。

从之前与数据库交互直接使用ADO.NET,到后来使用SQLHelper类,再到使用Dapper,后来才了解到还有ORM的存在,开始学习使用EFCore、SQLSugar,还有一个FreeSQL还没使用过。最开始的实践基本上都是基于事件驱动编程,在Winform中通过拖拽设计页面,在写一下事件处理函数,就这些写了好几个月,我都不知道C#中的委托与事件为何物,更不用说Linq与异步了。

研究生阶段总是对就业充满焦虑的,我也知道.NET在国内确实不是主流,而且也发现现在B/S架构比C/S架构更流行。

23年2月开始学习html、css、javascript,说是学习,其实没有真的先去学这些再上手,而是直接去使用LayUI进行web前端的学习。

image-20240723110916897

23年3月开始看B站的黑马程序员的SpringBoot的教程学习,学习了SpringBoot+MyBatis Plus基本的CRUD操作。

image-20240723111232710

但是也明白现在java太卷了,而且自己真的不喜欢java八股文,什么jvm调优、分布式、高并发、微服务,这些东西还没接触过,如果只是为了面试死记硬背,自己没有真的在项目中使用到,感觉很虚。看了现在java招聘的要求,除了掌握Spring Boot、Spring Cloud、MyBatis外,还要掌握各种中间件如缓存数据库Redis、消息队列kafka、rabbitMQ等、Elasticsearch等,数据库不局限于关系型数据库MySQL、PostgreSQL还需要掌握非关系型数据库MongoDB。刚开始学习看到这些让我畏惧,于是又陷入到迷茫中。

23年4月,觉得畏惧java那就继续C#吧。在B站上观看杨中科老师的.NET6教程视频,买了杨中科老师的《ASP.NET Core技术内幕与项目实战》,但由于读研还需要做其他的事情,web开发这块都没有涉及到,教程和这本书没有看完,但还是从中学到了很多东西。

image-20240723111708685

23年5-6月,觉得要不试试C++与嵌入式吧,网上说不用担心35岁失业,越老越吃香。开始学习侯捷老师的C++视频,买了《C++ Primer》开始看。

image-20240723113518964

由于之前学过Winform,就开始了C++的跨平台UI框架Qt的学习。嵌入式方面花了两百多买了一块STM32开发板。这块板子到现在为止我还没有动过。

就是在买了这块板子之后,我突然意识到这样下去不行,学啥都只是学了个皮毛,学啥都坚持不下去,一直很焦虑未来的就业。

学编程要么为了兴趣要么为了赚钱。我发现我其实很喜欢C#,对.NET也有兴趣,我想先不考虑就业的问题,毕竟当时才研一下,距离找工作还有一段时间。我决定基于兴趣去搞事去玩,想用C#干啥就干啥,不考虑当下的市场情况。

但是读研也并不简单,不是你想干什么就能干什么的,你需要做你的课题研究,也要帮老师做横向。可能很多人都没听说过Modelica,Modelica是一种面向对象的语言,用于对信息物理系统进行建模。它支持由数学方程控制的可重用组件的因果连接,以促进从第一性原理进行建模。简单的说就是能更高效地对复杂系统进行建模,可以用它来进行物理系统的仿真建模与matlab中的simulink类似。

image-20240723115245154

23年9月-24年6月其实我研究生生涯的主要任务是使用MWorks.Sysplorer+Modelica进行某个物理系统的仿真计算。

23年6月至今,基于兴趣爱好学习C#的一些历程分享:

  • 用C#干爬虫:C# + Html Agility Pack + HttpClient + Selenium/Playwright爬取并解析静动态网页数据。
  • 用C#玩AI:C# + NumSharp实现最小二乘法并使用Scottplot或OxyPlot绘图;用ML.NET Model Builder做了个简单的猫狗识别,并在.NET应用中集成。用C#训练模型其实并不推荐,还是主流的python的Pytorch比较好,但是可以使用ONNX与OpenVINO推理模型;最近LLM这么火,将LLM与自己的应用集成,除了可以用python的langchain外也可以用SemanticKernel实现相同的目的,还可以结合向量数据库、提示词工程等,做一些Rag应用,现在还有GraphRAG,都很值得尝试,去做一些AI Agent应用多好玩啊。
  • 用C#做客户端:C#技术栈中涉及到客户端可用的很多,winform简单易上手,基于事件驱动很好理解,缺点做出好看的界面不容易,虽然现在可以靠Blazor Hybrid但本质上不算是winform算webview了以及不能跨平台;使用WPF,基于数据驱动,配合MVVM模式可以很好的实现UI与业务逻辑的解耦,使用xaml构建页面更加灵活,WPF中还有很多很好的设计,如数据绑定,命令,依赖属性等等,WPF很强大,我也很喜欢WPF,缺点还是不能跨平台。WinUI3也可以做现代化风格的Windows应用,缺点还是不能跨平台。跨平台客户端的方案有Avalonia、Uno Platform、Maui,其中Maui可以结合Blazor Hybrid使用一些Blazor组件库可以快速构建美观的页面。
  • 用C#做后端:ASP.NET Core MVC,前后端分离可以Vue/React+ASP.NET Core Web Api+EFCore/SQL Sugar/FreeSql +SqlServer/MySql/PostgreSQL+MongoDB+Redis/Garnet等技术栈,前后端不分离可以使用Blazor,使用C#前后端都搞定,构建项目效率很高。

今后的实践探索方向

学编程要么为了好玩要么为了赚钱,一直基于兴趣完全不考虑赚钱太理想主义了,我也明白.NET在国内的岗位不算多,特别是大公司的岗位。之前考虑兴趣与赚钱在国内能更好的结合,我主要学习WPF技术,我觉得WPF技术在国内就业岗位还是有的。WPF是个好东西,但是很多人都不认识,哈哈可以说别人不“识货”,但这是个人没法改变的。随着就业压力的增加,我决定不再局限于.NET,拓展自己的技术栈,为以后的就业增加竞争力。TypeScript同样出自我很崇拜的C#语言创建者Anders Hejlsberg之手,单纯知道这个我就很想学习使用TypeScript。

image-20240723143304678

今后可能会探索的技术栈。

前端:语言 TypeScript / JavaScript,框架 Vue3/React

后端:语言 C#/Go/Java,框架ASP.NET Core Web API/Spring Boot

客户端:语言C#/Dart,框架WPF/Avalonia/Flutter

人工智能相关:语言C#/Python,框架SemanticKernel/Pytorch/langchain

虽然说语言与框架的思想总有相通之处,但生态、相关工具链、以及各种解决方案的掌握都需要时间。而且也只有真的实践了,才会真的积累有效的经验。

为什么写公众号?

其实做公众号初心不是为了赚钱,虽然说由于关注人数的增加与阅读量的增加,现在每个月都能有几十块的收入。

image-20240723145336461

image-20240723145410695

除去OSS的成本每个月几块钱,每个月也还能赚几十块。

但是如果想通过这种方式搞钱付出回报比太低了,你写那么多原创文章,还是付出了一定的时间精力的,但是赚到的钱还不够去外面吃顿饭还不够买几杯奶茶,如果只是为了赚钱,估计早就放弃了。

其实做公众号最主要还是为了自己,于我个人而言,发表博客能给我带来正反馈,当有人点赞、收藏、分享的时候,我就会有成就感,觉得自己的分享是有价值的,对别人是有帮助的,而且知识的掌握,也不是一蹴而就的,自己时不时的回顾也很方便,如果还能帮助到有需要的人,那就更有意义了。获取正反馈之后,学习更有动力与兴趣,更会分享,更分享更有正反馈,就形成了一个良好的循环,我也发现这样做,比之前的学习都更持久更加有收获。因为当你入门一个东西,如果半天没有成果,没有积累起成就感就会很容易放弃的。

目前文章同步分享在微信公众号、博客园、CSDN、稀土掘金、知乎。

image-20240723151525860

image-20240723151704749

通过网络在广袤的时空中有幸与各位产生联结,如果文章对您有所帮助那就太好了,接下来会根据自己的探索实践,分享一些.NET技术栈之外的东西,当然还是以C#相关的为主,当成是个人学习的记录,如果对您有用可以继续关注,如果觉得没有帮助了取消关注也没有关系,仅以此文回顾自己.NET的学习历程与确定未来探索方向,祝各位都能奔赴自己的星辰大海!

〇、前言

本文介绍了如何通过 vim 命令,对文本文件进行打开、编辑、保存等相关操作,并通过简单的示例演示了常用用法。

一、关于文本文件的操作

1.1 打开,查看(cat)、编辑(vim)

打开文本文件,有查看和编辑两种状态。

1.1.1 仅查看 cat

可以使用 cat 命令
,加上文件的绝对路径或者进入目标路径加上文件名,如下示例:

// 例如要查看此文件:/etc/test/system.config
cat /etc/test/system.config
// 或者
cd /etc/test
cat system.config

1.1.2 编辑 vim

使用 vim 命令
,同样是加上绝对路径或进入目标路径加上文件名,如下示例:

// 例如要编辑此文件:/etc/test/system.config
vim /etc/test/system.config
// 或者
cd /etc/test
vim system.config

另外还有个 vi 命令,它实际上是 vim 的前身,vim 全部包含了 vi 的功能,并进行了许多扩展和改进,因此更推荐使用 vim。

另外,为了便于操作并增加可读性,可以
添加行号

// 在通过 vim 打开文件之后,执行如下命令:
// 【临时显示行号】两个命令均可:
:set nu
:set number
// 【临时隐藏行号】两个命令均可:
:set nonu  // no + nu
:set nonumber  // no + number
// 【永久显示行号】需要在配置文件中修改,加上默认的命令
// 1)通过 find 命令,查找 vimrc 配置文件的路径
find / -name vimrc  // 结果:/etc/vimrc
// 2)编辑配置文件
vim /etc/vimrc
// 在文件最后新增行加入命令:
set nu
// 最后用命令【:wq】保存并退出即可

1.1.3 文件内容翻页

命令 简介
Ctrl+u 向文件首翻半屏
Ctrl+d 向文件尾翻半屏
Ctrl+b 向文件首翻一屏
Ctrl+f 向文件尾翻一屏
z 将当前行翻滚到屏幕顶部
nz 将本文件第 n 行翻滚到屏幕顶部

1.2 编辑文本内容

1.2.1 插入文本内容

通过 vim 命令打开文件后,实际上是无法编辑的,还要通过命令来进入编辑状态。

这个命令很简单,就是一个英文字母,如下六个类型:

a 在当前
光标选中的字符,之后插入字符
A 在当前光标所在行的
行尾插入字符,行尾包含空格
i 在当前
光标选中的字符,之前插入字符
I 在当前光标所在行的
行首插入字符,行首不包含空格
o 在当前光标所在行的
下方插入一个空行
,并在空行中输入字符
O 在当前光标所在行的
上方插入一个空行
,并在空行中输入字符

1.2.2 查找

文本内容查找,可以有两种方式,通过“/”、grep 命令。

1
)通过“/”加要查询字符串,需要在文件中查找

下面例举几个示例:

语法 简介 示例(abc 代表普通字符串)
/+字符串 简单的匹配字符串 /abc
/^+字符串 开头为指定字符串 /^abc
/字符串+$ 结尾为指定字符串 /abc$
?+字符串 简单查询,当前位置开始往前查找,锁定第一个 ?abc

快捷键:

n:往上查找;

N:往下查找。

2)通过 grep 命令在不打开文件的情况下查找文本内容

grep 的名称来源于“global regular expression print”,意为全局正则表达式打印。

// 基本语法:
grep [选项] '模式' 文件名
// [选项]:是可选的,用于定制grep的行为
// '模式':是要搜索的字符串或正则表达式
// 文件名:是要搜索的文件名或目录名

常用的选项:

选项 简介
-i 忽略大小写
-v 反向匹配,选择不匹配的行
-r 或 -R 递归搜索,在当前目录及子目录中的文件搜索
-l 只输出包含匹配行的文件名
-n 显示匹配行及其行号
-c 只输出匹配的行数
-o 只输出匹配的部分
-A num 显示匹配行
之后
的 num 行
-B num 显示匹配行
之前
的 num 行
-C num 或 --context=num 显示匹配行
前后各
num 行
-e 指定多个模式
-f 从文件中获取模式
--color 或 --colour 高亮显示匹配部分

几个简单的示例:

// 简单查找
// 在 text2.txt 中查找包含 345 字符串的所有行
grep '345' /etc/test/text2.txt

// 反向匹配,即不包含目标字符串的行
// 在 text2.txt 中查找不包含 345 字符串的所有行
grep -v '345' /etc/test/text2.txt

// 反向匹配,即不包含目标字符串的行
// 在 text2.txt 中查找以 4~5 结尾的所有行
grep '[4-5]$' /etc/test/text2.txt

选线组合使用:(递归搜索的同时,只返回文件名)

// 列出 /path/to/directory 目录及其子目录中,所有包含 pattern 的文件名
grep -rl 'pattern' /path/to/directory/

grep 参考: https://www.cnblogs.com/huangjiabobk/p/18106391

1.2.3 替换

一般模式

从查看状态进入替换模式,有如下几个快捷键:

r:仅替换当前选退出编辑状态;

R:连续替换后续光标选中的字符串,直至单击 Esc 键退出编辑;

cc:清空当前行,然后自动进入 insert 模式,可直接输入字符串;

cw:删除从光标位置开始往后的单词,即至下一个空格为止或遇到标点符号为止;

~:切换光标选中字符的大小写

命令模式

命令 简介
:s/str_old/str_new/g
i
仅当前行中
,用 string_new 替换掉 string_old,最后的配置 i 标识不区分大小写
:row_start,row_end
s
/str_old/str_new/g
i
注:row_end 后边有个额外的字母 s
根据行号扩大范围
,从 row_start 行到 row_end 行,用 string_new 替换掉 string_old
:g/str_old/str_new/g
i
当前文件中全文搜索
,用 string_new 替换掉 string_old

注:可再打开文件后,临时通过【:set nu】显示行号。

示例:【:3,4s/345/666/g】如下图,替换前后:

1.2.4 删除(剪切)

删除实际上是的效果就是剪切,通过快捷键【p】粘贴到光标之后。

命令 简介
x 删除光标选中的一个字符
X 删除光标选中位置前的一个字符
dd 删除光标所在的行,光标回到上一行的开始
ndd 从光标所在行开始,往后删除 n 行
dG 删除从光标所在行开始,往后知道文件结尾的全部行
dnG 删除从光标所在行开始,到文件中第 n 行的全部行
D 删除当前行中,光标位置之后的全部字符
:num_start,num_end
d
删除从 num_start 到 num_end 之间的全部行

示例:

文件查看状态输入【2dd】,从光标所在行开始,往后删除 2 行:

文件查看状态输入【:9,10d】,按行号删除第 9 到 10 行:

1.2.5 复制和粘贴

命令 简介
yy 复制当前行至剪切板
nyy 从当前行开始,复制 n 行至剪切板
yw 复制从光标位置开始的一个单词,即
至下一个空格或标点符号
为止的字符串
p 粘贴,将剪切板中的内容,粘贴到光标位置之
P 粘贴,将剪切板中的内容,粘贴到光标位置之

示例:

文件查看状态下,输入【2yy】复制当前行开始往后两行,再输入命令【P】,粘贴到光标位置之前,前后结果如下图:

1.2.6 撤销和恢复

文件查看状态下,如下快捷键:

u:撤销上一步编辑操作;

Ctrl+r:恢复上一步撤销操作。

1.3 保存和退出

命令 简介
:wq 保存并退出
:wq! 保存并强制退出
:q 不保存退出
:q! 不保存强制退出
:w 保存,不退出
:w! 强制保存,不退出
:w filename 另存为,filename 为新的文件名
x! 保存并退出
ZZ 直接退出

参考: https://blog.csdn.net/qinfuan2017/article/details/79728906 https://blog.csdn.net/qq_29689343/article/details/116162089

算法基础

\(\text{Update: 2024 - 07 - 22}\)

复杂度

定义

衡量一个算法的快慢,一定要考虑数据规模的大小。

一般来说,数据规模越大,算法的用时就越长。

而在算法竞赛中,我们衡量一个算法的效率时,最重要的不是看它在某个数据规模下的用时,而是看它的用时随数据规模而增长的趋势,即
时间复杂度

符号定义

相等是
\(\Theta\)
,小于是
\(O\)
,大于是
\(\Omega\)


\(\Theta\)
符号

对于函数
\(f(n)\)

\(g(n)\)

\(f(n)=\Theta(g(n))\)
,当且仅当
\(\exists c_1,c_2,n_0>0\)
,使得
\(\forall n \ge n_0, 0\le c_1\cdot g(n)\le f(n) \le c_2\cdot g(n)\)


\(O\)
符号

\(\Theta\)
符号同时给了我们一个函数的上下界,如果只知道一个函数的渐进上界而不知道其渐进下界,可以使用
\(O\)
符号。
\(f(n) = O(g(n))\)
,当且仅当
\(\exists c,n_0\)
,使得
\(\forall n \ge n_0,0\le f(n)\le c\cdot g(n)\)

研究时间复杂度时通常会使用
\(O\)
符号,因为我们关注的通常是程序用时的上界,而不关心其用时的下界。


\(\Omega\)
符号

同样的,我们使用
\(\Omega\)
符号来描述一个函数的渐进下界。
\(f(n) = \Omega(g(n))\)
,当且仅当
\(\exists c, n_0\)
,使得
\(\forall n \ge n_0, 0 \le c \cdot g(n) \le f(n)\)

主定理

建议背下来,不是很好理解。

\[T(n) = aT(\frac{n}{b}) + f(n) \qquad \forall n > b
\]

那么

\[T(n) = \begin{cases} \Theta(n^{\log_b a}) & f(n) = O(n^{\log_b (a) - \epsilon}), \epsilon > 0 \\ \Theta(f(n)) & f(n) = \Omega(n^{\log_b (a) + \epsilon}), \epsilon\ge 0\\ \Theta(n^{\log_b a} \log^{k+1} n) & f(n) = \Theta(n^{\log_b a} \log^k n), k \ge 0 \end{cases}
\]

需要注意的是,这里的第二种情况还需要满足
\(\text{regularity condition}\)
, 即
\(a f(n/b) \leq c f(n)\)

\(\text{for some constant}\)
\(c < 1\)
\(\text{and sufficiently large}\)
\(n\)

几个例子:

  1. \(T(n) = 2T(\frac{n}{2}) + 1\)
    ,那么
    \(a = 2, b = 2, {\log_2 2} = 1\)
    ,那么
    \(\epsilon\)
    可以取值在
    \((0, 1]\)
    之间,从而满足第一种情况,所以
    \(T(n) = \Theta(n)\)

  2. \(T(n) = T(\frac{n}{2}) + n\)
    ,那么
    \(a = 1, b = 2, {\log_2 1} = 0\)
    ,那么
    \(\epsilon\)
    可以取值在
    \((0, 1]\)
    之间,从而满足第二种情况,所以
    \(T(n) = \Theta(n)\)

  3. \(T(n) = T(\frac{n}{2}) + {\log n}\)
    ,那么
    \(a = 1, b = 2, {\log_2 1} = 0\)
    ,那么
    \(k\)
    可以取值为
    \(1\)
    ,从而满足第三种情况,所以
    \(T(n) = \Theta(\log^2 n)\)

  4. \(T(n) = T(\frac{n}{2}) + 1\)
    ,那么
    \(a = 1, b = 2, {\log_2 1} = 0\)
    ,那么
    \(k\)
    可以取值为
    \(0\)
    ,从而满足第三种情况,所以
    \(T(n) = \Theta(\log n)\)

空间复杂度

类似地,算法所使用的空间随输入规模变化的趋势可以用
空间复杂度
来衡量。

枚举

实际上一些题的正解就是枚举,但需要很多优化。

要点:

  1. 建立简洁的数学模型。

  2. 减少不必要的枚举空间。

  3. 选择合适的枚举顺序。

模拟

模拟即为将题目描述中的操作用代码实现,码量一般比较大,写错比较难调,相当浪费时间。

写模拟题时有以下注意的点:

  • 在写代码之前,尽量在演草纸上自习分析题目的实现过程。

  • 在代码中尽量把每个部分抽离出来写成函数,模块化。

  • 将题目中的信息转化,不要残留多种表达。

  • 如果一次没过大样例,分块调试。

  • 实现代码时按照演草纸上的思路一步步实现。

递归

定义

我们可以用以下代码理解递归:

long long f(传入数值) {
    if(终止条件) return 最小子问题解;
    return f(缩小问题规模);
}

递归的优点

  • 结构清晰、可读性强,可以参考归并排序的两种实现方式。
  • 练习分析为题的结构,当发现问题可以分解成相同结构的小问题时,递归写多了就能敏锐发现这个特点,进而高效解决问题。

递归的缺点

在程序执行中,递归是利用堆栈来实现的。每当进入一个函数调用,栈就会增加一层栈帧,每次函数返回,栈就会减少一层栈帧。而栈不是无限大的,当递归层数过多时,就会造成
栈溢出
的后果。

显然有时候递归处理是高效的,比如归并排序;
有时候是低效的
,比如数孙悟空身上的毛,因为堆栈会消耗额外空间,而简单的递推不会消耗空间。比如这个例子,给一个链表头,计算它的长度:

// 典型的递推遍历框架
int size(Node *head) {
  int size = 0;
  for (Node *p = head; p != nullptr; p = p->next) size++;
  return size;
}

// 我就是要写递归,递归天下第一
int size_recursion(Node *head) {
  if (head == nullptr) return 0;
  return size_recursion(head->next) + 1;
}

二者的对比,compiler 设为 Clang 10.0,优化设为 O1

递归优化见后文
搜索优化

记忆化搜索

要点

明白一个函数的作用并相信它能完成这个任务,千万不要跳进这个函数里面企图探究更多细节,否则就会陷入无穷的细节无法自拔,人脑能压几个栈啊。

递归与枚举的区别

枚举是横向地把问题划分,然后依次求解子问题;而递归是把问题逐级分解,是纵向的拆分。

递归与分治的区别

递归是一种编程技巧,一种解决问题的思维方式;分治算法很大程度上是基于递归的,解决更具体问题的算法思想。

分治

定义

就是把一个复杂的问题分成两个或更多的相同或相似的子问题,直到最后子问题可以简单的直接求解,原问题的解即子问题的解的合并。

过程

分治算法的核心思想就是「分而治之」。

大概的流程可以分为三步:分解
\(\to\)
解决
\(\to\)
合并。

  1. 分解原问题为结构相同的子问题。

  2. 分解到某个容易求解的边界之后,进行递归求解。

  3. 将子问题的解合并成原问题的解。

分治法能解决的问题一般有如下特征:

  • 该问题的规模缩小到一定的程度就可以容易地解决。

  • 该问题可以分解为若干个规模较小的相同问题,即该问题具有最优子结构性质,利用该问题分解出的子问题的解可以合并为该问题的解。

  • 该问题所分解出的各个子问题是相互独立的,即子问题之间不包含公共的子问题。

注意:如果各子问题是不独立的,则分治法要重复地解公共的子问题,也就做了许多不必要的工作。此时虽然也可用分治法,但一般用动态规划较好。

贪心

适用范围

贪心算法在有最优子结构的问题中尤为有效。

最优子结构的意思是问题能够分解成子问题来解决,子问题的最优解能递推到最终问题的最优解。

证明

贪心算法有两种证明方法:反证法和归纳法。

一般情况下,一道题只会用到其中的一种方法来证明。

  1. 反证法:如果交换方案中任意两个元素/相邻的两个元素后,答案不会变得更好,那么可以推定目前的解已经是最优解了。

  2. 归纳法:先算得出边界情况(例如
    \(n = 1\)
    )的最优解
    \(F_1\)
    ,然后再证明:对于每个
    \(n\)

    \(F_{n + 1}\)
    都可以由
    \(F_{n}\)
    推导出结果。

常见题型

在提高组难度以下的题目中,最常见的贪心有两种。

  • 「我们将
    \(XXX\)
    按照某某顺序排序,然后按某种顺序(例如从小到大)选择。」。

  • 「我们每次都取
    \(XXX\)
    中最大/小的东西,并更新
    \(XXX\)
    。」(有时「
    \(XXX\)
    中最大/小的东西」可以优化,比如用优先队列维护)

二者的区别在于一种是离线的,先处理后选择;一种是在线的,边处理边选择。

排序解法

用排序法常见的情况是输入一个包含几个(一般一到两个)权值的数组,通过排序然后遍历模拟计算的方法求出最优值。

后悔解法

思路是无论当前的选项是否最优都接受,然后进行比较,如果选择之后不是最优了,则反悔,舍弃掉这个选项;否则,正式接受。如此往复。

排序

几种排序算法的比较

一张表格速通

排序方法 平均情况 最好情况 最坏情况 空间 稳定性
选择排序 \(O(n^2)\) \(O(n^2)\) \(O(n^2)\) \(O(1)\) 不稳定
冒泡排序 \(O(n^2)\) \(O(n)\) \(O(n^2)\) \(O(1)\) 稳定
插入排序 \(O(n^2)\) \(O(n)\) \(O(n^2)\) \(O(n)\) 稳定
计数排序 \(O(n + w)^1\) \(O(n + w)\) \(O(n + w)\) \(O(n + w)\) 稳定
基数排序 \(O(kn + \sum\limits_{i = 1}^{k} w_i)^2\) \(O(kn + \sum\limits_{i = 1}^{k} w_i)\) \(O(kn + \sum\limits_{i = 1}^{k} w_i)\) \(O(n + k)\) 稳定
快速排序 \(O(n \log n)\) \(O(n \log n)\) \(O(n^2)\) \(O(\log n) \sim O(n)\) 不稳定
归并排序 \(O(n \log n)\) \(O(n \log n)\) \(O(n \log n)\) \(O(n)\) 稳定
堆排序 \(O(n \log n)\) \(O(n \log n)\) \(O(n \log n)\) \(O(1)\) 不稳定
桶排序 \(O(n + \frac{n^2}{k} + k)^3\) \(O(n)\) \(O(n^2)\) \(O(n + m)^4\) 稳定
希尔排序 \(O(n \log n) \sim O(n^2)\) \(O(n^{1.3})\) \(O(n^2)\) \(O(1)\) 不稳定
锦标赛排序 \(O(n \log n)\) \(O(n \log n)\) \(O(n \log n)\) \(O(n)\) 稳定
\(\text{tim}\)
排序
\(O(n \log n)\) \(O(n)\) \(O(n \log n)\) \(O(n)\) 稳定

\(^1\)
:其中
\(w\)
为排序元素的值域。

\(^2\)
:其中
\(k\)
为排序元素的最大位数,
\(w_i\)
为第
\(i\)
个关键字的值域。

\(^3\)
:其中
\(k\)
表示将
\(n\)
个元素放进
\(m\)
个桶中,每个桶的数据为
\(k = \frac{n}{m}\)

\(^4\)
:其中
\(m\)
表示将
\(n\)
个元素放进的
\(m\)
个桶。

前缀和

定义

前缀和可以简单理解为「数列的前
\(\text{i}\)
项的和」,是一种重要的预处理方式,能大大降低查询的时间复杂度。

一维前缀和

预处理递推式为:

\[p_i = p_{i - 1} + a_i
\]

查询
\([l, r]\)
的数值:

\[ans = p_r - p_{l - 1}
\]

二维前缀和

预处理递推式为:

\[p_{i, j} = p_{i - 1, j} + p_{i, j - 1} - p_{i - 1, j - 1} + a_{i, j}
\]

查询
\((i, j) \sim (k, w)\)
的数值:

\[ans = p_{k, w} - p_{k, j - 1} - p_{i - 1, w} + p_{i - 1, j - 1}
\]

树上前缀和


\(s_i\)
表示结点
\(i\)
到根节点的权值总和。

若是点权,
\(x, y\)
路径上的和为
\(s_x + s_y - s_{lca} - s_{fa_{lca}}\)

若是边权,
\(x, y\)
路径上的和为
\(s_x + s_y - 2 \times s_{lca}\)

差分

定义

差分是一种和前缀和相对的策略,可以当作是求和的逆运算。

这种策略的定义是令
\(b_i = \begin{cases} a_i - a_{i - 1} \, &i \in[2, n] \\ a_1 \, &i = 1 \end{cases}\)

性质

  • \(a_i\)
    的值是
    \(b_i\)
    的前缀和,即
    \(a_n = \sum\limits_{i = 1}^n b_i\)

  • 计算
    \(a_i\)
    的前缀和
    \(sum = \sum\limits_{i = 1}^n a_i = \sum\limits_{i = 1}^n \sum\limits_{j = 1}^{i} b_j = \sum\limits_{i = 1}^n (n - i + 1) b_i\)

它可以维护多次对序列的一个区间加上一个数,并在最后询问某一位的数或是多次询问某一位的数。注意修改操作一定要在查询操作之前。

树上差分

树上差分可以理解为对树上的某一段路径进行差分操作,这里的路径可以类比一维数组的区间进行理解。例如在对树上的一些路径进行频繁操作,并且询问某条边或者某个点在经过操作后的值的时候,就可以运用树上差分思想了。

树上差分通常会结合树基础和最近公共祖先来进行考察。树上差分又分为
点差分

边差分
,在实现上会稍有不同。

点差分

举例:对树上的一些路径
\(\delta(s_1, t_1), \delta(s_2, t_2), \delta(s_3, t_3)\dots\)
进行访问,问一条路径
\(\delta(s, t)\)
上的点被访问的次数。

对于一次
\(\delta(s, t)\)
的访问,需要找到
\(s\)

\(t\)
的公共祖先,然后对这条路径上的点进行访问(点的权值加一),若采用
\(\text{DFS}\)
算法对每个点进行访问,由于有太多的路径需要访问,时间上承受不了。这里进行差分操作:

\[\begin{aligned}
&d_s\leftarrow d_s + 1\\
&d_{lca}\leftarrow d_{\textit{lca}} - 1\\
&d_t\leftarrow d_t + 1\\
&d_{f(\textit{lca})}\leftarrow d_{f(\textit{lca})} - 1\\
\end{aligned}
\]

其中
\(f(x)\)
表示
\(x\)
的父亲节点,
\(d_i\)
为点权
\(a_i\)
的差分数组。

可以认为公式中的前两条是对蓝色方框内的路径进行操作,后两条是对红色方框内的路径进行操作。不妨令
\(\textit{lca}\)
左侧的直系子节点为
\(\textit{left}\)
。那么有
\(d_{\textit{lca}} - 1 = a_{\textit{lca}} - (a_{\textit{left}} + 1)\)

\(d_{f(\textit{lca})} - 1 = a_{f(\textit{lca})} - (a_{\textit{lca}} + 1)\)
。可以发现实际上点差分的操作和上文一维数组的差分操作是类似的。

边差分

若是对路径中的边进行访问,就需要采用边差分策略了,使用以下公式:

\[\begin{aligned}
&d_s\leftarrow d_s + 1\\
&d_t\leftarrow d_t + 1\\
&d_{\textit{lca}}\leftarrow d_{\textit{lca}} - 2\\
\end{aligned}
\]

由于在边上直接进行差分比较困难,所以将本来应当累加到红色边上的值向下移动到附近的点里,那么操作起来也就方便了。对于公式,有了点差分的理解基础后也不难推导,同样是对两段区间进行差分。

\(\text{Update: 2024 - 07 - 23}\)

二分

时间复杂度

二分查找的最优时间复杂度为
\(O(1)\)
,平均时间复杂度和最坏时间复杂度均为
\(O(\log n)\)

空间复杂度

迭代版本的二分查找的空间复杂度为
\(O(1)\)

递归(无尾调用消除)版本的二分查找的空间复杂度为
\(O(\log n)\)

三分法

三分法衍生自二分法,三分法求单峰函数的峰值。

算法流程:

设当前搜索域为
\([l,r]\)
,取该区间的三等分点
\(\text{lmid,rmid}\)

若满足
\(f(\text{lmid})<f(\text{rmid})\)
,则可以排除
\([l,\text{lmid}]\)

若满足
\(f(\text{lmid})=f(\text{rmid})\)
,则可以排除
\([l,\text{lmid}] \cup [\text{rmid},r]\)

若满足
\(f(\text{lmid})>f(\text{rmid})\)
,则可以排除
\([\text{rmid},r]\)

实数三分

double l = L, r = R;
for(int i = 1; i <= 100; i ++) {
    double lmid = l + (r - l) / 3, rmid = r - (r - l) / 3;
    if(f(lmid) < f(rmid)) l = lmid;
    else r = rmid;
}
double ans = f(l);

整数三分

long long l = L, r = R;
while(r - l > 3) {
    long long lmid = l + (r - l) / 3, rmid = r - (r - l) / 3;
    if(f(lmid) < f(rmid)) l = lmid;
    else r = rmid;
}
long long ans = f(l);
for(int i = l + 1; i <= r; i ++) ans = max(ans, f(i));

算法优化:

考虑计算时间复杂度,由于每次搜索域缩减到
\(\frac{2}{3}\)

因此时间复杂度为
\(O(\log_{\frac{3}{2}} \frac{R - L}{eps})\)

我们完全可以将
\(\text{lmid}\)

\(\text{rmid}\)
不断接近,以此达到使
\(\log\)
的底数无限接近
\(2\)

在单峰性/单谷性能够证明的题目中,三分法都能高效地适配。

分数规划

分数规划通常描述为下列问题:每个物品有两个属性
\(c_i, d_i\)
,要求通过某种方式选出若干个,使得
\(\frac{\sum c_i}{\sum d_i}\)
最大或最小。

经典的例子有最优比率环、最优比率生成树等等。

分数规划可以用二分法来解决。

倍增

倍增在很多算法中均有运用,其中最常用的是
\(\text{RMQ}\)
问题和求
\(\text{LCA}\)

构造

构造题是比赛中常见的一类题型。

从形式上来看,问题的答案往往具有某种规律性,使得在问题规模迅速增大的时候,仍然有机会比较容易地得到答案。

这要求解题时要思考问题规模增长对答案的影响,这种影响是否可以推广。

例如,在设计动态规划方法的时候,要考虑从一个状态到后继状态的转移会造成什么影响。

特点

构造题一个很显著的特点就是高自由度,也就是说一道题的构造方式可能有很多种,但是会有一种较为简单的构造方式满足题意。

看起来是放宽了要求,让题目变的简单了,但很多时候,正是这种高自由度导致题目没有明确思路而无从下手。

构造题另一个特点就是形式灵活,变化多样。并不存在一个通用解法或套路可以解决所有构造题,甚至很难找出解题思路的共性。

例题

例一:

构造一组
\(x,y,z\)
,使得对于给定的
\(n\)
,满足
\(\dfrac{1}{x} + \dfrac{1}{y} + \dfrac{1}{z} = \dfrac{2}{n}\)

解题思路:

显然
\(n, n + 1, n(n + 1)\)
为一组合法解。特殊地,当
\(n = 1\)
时,无解,这是因为
\(n + 1\)

\(n(n + 1)\)
此时相等。

至于构造思路是怎么产生的,大概就是观察样例加上一点点数感了吧。此题对于数学直觉较强的人来说并不难。

例二:

\(\text{Task1}\)
:试判断能否构造并构造一个长度为
\(n\)

\(1\dots n\)
的排列,满足其
\(n\)
个前缀和在模
\(n\)
的意义下互不相同

\(\text{Task2}\)
:试判断能否构造并构造一个长度为
\(n\)

\(1\dots n\)
的排列,满足其
\(n\)
个前缀积在模
\(n\)
的意义下互不相同

解题思路:

对于
\(\text{task1}\)


\(n\)
为奇数时,无法构造出合法解;


\(n\)
为偶数时,可以构造一个形如
\(n, 1, n - 2, 3, \cdots\)
这样的数列。

首先,我们可以发现
\(n\)
必定出现在数列的第一位,否则
\(n\)
出现前后的两个前缀和必然会陷入模意义下相等的尴尬境地;

然后,我们考虑构造出整个序列的方式:

考虑通过构造前缀和序列的方式来获得原数列,可以发现前缀和序列两两之间的差在模意义下不能相等,因为前缀和序列的差分序列对应着原来的排列。

因此我们尝试以前缀和数列在模意义下为

\[0, 1, -1, 2, -2, \cdots
\]

这样的形式来构造这个序列,不难发现它完美地满足所有限制条件。

对于
\(\text{task2}\)


\(n\)
为除
\(4\)
以外的合数时,无法构造出合法解


\(n\)
为质数或
\(4\)
时,可以构造一个形如
\(1, \dfrac{2}{1}, \dfrac{3}{2}, \cdots, \dfrac{n - 1}{n - 2}, n\)
这样的数列

先考虑什么时候有解:

显然,当
\(n\)
为合数时无解。因为对于一个合数来说,存在两个比它小的数
\(p,q\)
使得
\(p \times q \equiv 0 \pmod n\)
,如
\((3 \times 6) \% 9 = 0\)
。那么,当
\(p, q\)
均出现过后,数列的前缀积将一直为
\(0\)
,故合数时无解。特殊地,我们可以发现
\(4 = 2 \times 2\)
,无满足条件的
\(p, q\)
,因此存在合法解。

我们考虑如何构造这个数列:


\(\text{task1}\)
同样的思路,我们发现
\(1\)
必定出现在数列的第一位,否则
\(1\)
出现前后的两个前缀积必然相等;而
\(n\)
必定出现在数列的最后一位,因为
\(n\)
出现位置后的所有前缀积在模意义下都为
\(0\)
。分析题目给出的几组样例以后发现,所有样例中均有一组合法解满足前缀积在模意义下为
\(1, 2, 3, \cdots, n\)
,因此我们可以构造出上文所述的数列来满足这个条件。那么我们只需证明这
\(n\)
个数互不相同即可。

我们发现这些数均为
\(1 \cdots n - 2\)
的逆元
\(+ 1\)
,因此各不相同,此题得解。

例三:

给定一个整数
\(N\)
,试构造一个节点数为
\(N\)
无向图。令节点编号为
\(1\ldots N\)
,要求其满足以下条件:

  • 这是一个简单连通图。
  • 存在一个整数
    \(S\)
    使得对于任意节点,与其相邻节点的下标和为
    \(S\)

保证输入数据有解。

解题思路:

通过分析
\(n = 3, 4, 5\)
的情况,我们可以找到一个构造思路。

构造一个完全
\(k\)
分图,保证这
\(k\)
部分和相等。则每个点的
\(S\)
均相等,为
\(\dfrac{(k - 1)\sum_{i = 1}^{n}i}{k}\)

如果
\(n\)
为偶数,那么我们可以前后两两配对,即
\(\{1, n\}, \{2, n - 1\} \cdots\)

如果
\(n\)
为奇数,那么我们可以把
\(n\)
单拿出来作为一组,剩余的
\(n - 1\)
个两两配对,即
\(\{n\}, \{1, n - 1\}, \{2, n - 2\}\cdots\)

这样构造出的图在
\(n \ge 3\)
时连通性易证,在此不加赘述。

此题得解。

搜索

\(\text{Update: 2024 - 07 - 24}\)

简介

搜索,也就是对状态空间进行枚举,通过穷尽所有的可能来找到最优解,或者统计合法解的个数。

搜索有很多优化方式,如减小状态空间,更改搜索顺序,剪枝等。

搜索是一些高级算法的基础,在
\(\text{OI}\)
中,纯粹的搜索往往也是得到部分分的手段,但可以通过纯粹的搜索拿到满分的题目非常少。

习题

DFS

先举一个例子:

把正整数
\(n\)
分解为
\(3\)
个不同的正整数,如
\(6 = 1 + 2 + 3\)
,排在后面的数必须大于等于前面的数,输出所有方案。

这个问题很简单,直接三层循环暴力就能解决:

for(int i = 1; i <= n; i ++) 
  for(int j = i + 1; j <= n; j ++)
    for(int k = j + 1; k <= n; k ++)
      if(i + j + k == n) cout << n << " = " << i << " + " << j << " + " << k << "\n";

但如果是把正整数
\(n\)
分为
\(m\)
个不同的数,那么单纯的循环就解决不了了,总不能写
\(m\)
层循环吧。

这时就要用到递归搜索了即
\(\text{DFS}\)

该类搜索算法的特点在于,将要搜索的目标分成若干「层」,每层基于前几层的状态进行决策,直到达到目标状态。

考虑上述问题,即将正整数
\(n\)
分解成小于等于
\(m\)
个正整数之和,且排在后面的数必须大于等于前面的数,并输出所有方案。

设一组方案将正整数
\(n\)
分解成
\(k\)
个正整数
\(a_1, a_2, \ldots, a_k\)
的和。

我们将问题分层,第
\(i\)
层决定
\(a_i\)
。则为了进行第
\(i\)
层决策,我们需要记录三个状态变量:
\(n-\sum_{j=1}^i{a_j}\)
,表示后面所有正整数的和;以及
\(a_{i-1}\)
,表示前一层的正整数,以确保正整数递增;以及
\(i\)
,确保我们最多输出
\(m\)
个正整数。

为了记录方案,我们用 arr 数组,第 i 项表示
\(a_i\)
. 注意到 arr 实际上是一个长度为
\(i\)
的栈。

int m, arr[103];  // arr 用于记录方案

void dfs(int n, int i, int a) {
  if(n == 0) {
    for (int j = 1; j <= i - 1; ++j) printf("%d ", arr[j]);
    printf("\n");
  }
  if(i <= m) {
    for(int j = a; j <= n; ++j) {
      arr[i] = j;
      dfs(n - j, i + 1, j);  
    }
  }
}

// 主函数
scanf("%d %d", &n, &m);
dfs(n, 1, 1);

BFS

\(\text{BFS}\)
是图论中的一种遍历算法,详见下文。

\(\text{BFS}\)
在搜索中也很常用,将每个状态对应为图中的一个点即可。

Meet in the middle

\(\text{Meet in the middle}\)
也称双向搜索。

算法的基本思路就是从状态图上的起点和终点同时开始
\(\text{DFS}\)

\(\text{BFS}\)

如果发现搜索的两端相遇了,那么可以认为是获得了可行解。

双向
\(\text{BFS}\)
的过程:

将开始结点和目标结点加入队列 q
标记开始结点为 1
标记目标结点为 2
while(队列 q 不为空) {
  从 q.front() 扩展出新的 s 个结点
  如果 新扩展出的结点已经被其他数字标记过
    那么 表示搜索的两端碰撞
    那么 循环结束
  如果 新的 s 个结点是从开始结点扩展来的
    那么 将这个 s 个结点标记为 1 并且入队 q 
  如果 新的 s 个结点是从目标结点扩展来的
    那么 将这个 s 个结点标记为 2 并且入队 q
}

性质

暴力搜索的复杂度往往是指数级的,而改用
\(\text{Meet in the middle}\)
算法后复杂度的指数可以减半,即
\(O(a^b) \to O(a^{\frac{b}{2}})\)

启发式搜索

定义

启发式搜索(英文:
\(\text{heuristic search}\)
)是一种在普通搜索算法的基础上引入了启发式函数的搜索算法。

启发式函数的作用是基于已有的信息对搜索的每一个分支选择都做估价,进而选择分支。简单来说,启发式搜索就是对取和不取都做分析,从中选取更优解或删去无效解。

例题

题目大意:


\(N\)
种物品和一个容量为
\(W\)
的背包,每种物品有重量
\(w_i\)
和价值
\(v_i\)
两种属性,要求选若干个物品(每种物品只能选一次)放入背包,使背包中物品的总价值最大,且背包中物品的总重量不超过背包的容量。

解题思路:

我们写一个估价函数
\(f\)
,可以剪掉所有无效的
\(0\)
枝条(就是剪去大量无用不选枝条)。

估价函数
\(f\)
的运行过程如下:

我们在取的时候判断一下是不是超过了规定体积(可行性剪枝);在不取的时候判断一下不取这个时,剩下的药所有的价值 + 现有的价值是否大于目前找到的最优解(最优性剪枝)。

示例代码:

#include <algorithm>
#include <cstdio>
using namespace std;
const int N = 105;
int n, m, ans;

struct Node {
  int a, b;  // a 代表时间,b 代表价值
  double f;
} node[N];

bool operator<(Node p, Node q) { return p.f > q.f; }

int f(int t, int v) {  // 计算在当前时间下,剩余物品的最大价值
  int tot = 0;
  for (int i = 1; t + i <= n; i++)
    if (v >= node[t + i].a) {
      v -= node[t + i].a;
      tot += node[t + i].b;
    } else
      return (int)(tot + v * node[t + i].f);
  return tot;
}

void work(int t, int p, int v) {
  ans = max(ans, v);
  if (t > n) return;                         // 边界条件:只有 n 种物品
  if (f(t, p) + v > ans) work(t + 1, p, v);  // 最优性剪枝
  if (node[t].a <= p) work(t + 1, p - node[t].a, v + node[t].b);  // 可行性剪枝
}

int main() {
  scanf("%d %d", &m, &n);
  for (int i = 1; i <= n; i++) {
    scanf("%d %d", &node[i].a, &node[i].b);
    node[i].f = 1.0 * node[i].b / node[i].a;  // f 为性价比
  }
  sort(node + 1, node + n + 1);  // 根据性价比排序
  work(1, m, 0);
  printf("%d\n", ans);
  return 0;
}

A*

定义

\(A^*\)
搜索算法(英文:
\(A^* \text{search algorithm}\)

\(A^*\)
读作
\(\text{A-star}\)
),简称
\(A^*\)
算法,是一种在图形平面上,对于有多个节点的路径求出最低通过成本的算法。它属于图遍历(英文:
\(\text{Graph traversal}\)
)和最佳优先搜索算法(英文:
\(\text{Best-first search}\)
),亦是
\(\text{BFS}\)
的改进。

过程

定义起点
\(s\)
,终点
\(t\)
,从起点(初始状态)开始的距离函数
\(g(x)\)
,到终点(最终状态)的距离函数
\(h(x)\)

\(h^{\ast}(x)^1\)
,以及每个点的估价函数
\(f(x) = g(x) + h(x)\)

\(A^*\)
算法每次从优先队列中取出一个
\(f\)
最小的元素,然后更新相邻的状态。

如果
\(h\leq h*\)
,则
\(A^*\)
算法能找到最优解。

上述条件下,如果
\(h\)
满足三角形不等式,则
\(A^*\)
算法不会将重复结点加入队列。


\(h=0\)
时,
\(A^*\)
算法变为
\(\text{Dijkstra}\)
;当
\(h=0\)
并且边权为
\(1\)
时变为
\(\text{BFS}\)

例题

八数码

题目大意:


\(3\times 3\)
的棋盘上,摆有八个棋子,每个棋子上标有
\(1\)

\(8\)
的某一数字。棋盘中留有一个空格,空格用
\(0\)
来表示。空格周围的棋子可以移到空格中,这样原来的位置就会变成空格。给出一种初始布局和目标布局(为了使题目简单,设目标状态如下),找到一种从初始布局到目标布局最少步骤的移动方法。

    123
    804
    765

解题思路:

\(h\)
函数可以定义为,不在应该在的位置的数字个数。

容易发现
\(h\)
满足以上两个性质,此题可以使用
\(A^*\)
算法求解。

参考代码:

#include <algorithm>
#include <cstdio>
#include <cstring>
#include <queue>
#include <set>
using namespace std;
const int dx[4] = {1, -1, 0, 0}, dy[4] = {0, 0, 1, -1};
int fx, fy;
char ch;

struct matrix {
  int a[5][5];

  bool operator<(matrix x) const {
    for (int i = 1; i <= 3; i++)
      for (int j = 1; j <= 3; j++)
        if (a[i][j] != x.a[i][j]) return a[i][j] < x.a[i][j];
    return false;
  }
} f, st;

int h(matrix a) {
  int ret = 0;
  for (int i = 1; i <= 3; i++)
    for (int j = 1; j <= 3; j++)
      if (a.a[i][j] != st.a[i][j]) ret++;
  return ret;
}

struct node {
  matrix a;
  int t;

  bool operator<(node x) const { return t + h(a) > x.t + h(x.a); }
} x;

priority_queue<node> q;  // 搜索队列
set<matrix> s;           // 防止搜索队列重复

int main() {
  st.a[1][1] = 1;  // 定义标准表
  st.a[1][2] = 2;
  st.a[1][3] = 3;
  st.a[2][1] = 8;
  st.a[2][2] = 0;
  st.a[2][3] = 4;
  st.a[3][1] = 7;
  st.a[3][2] = 6;
  st.a[3][3] = 5;
  for (int i = 1; i <= 3; i++)  // 输入
    for (int j = 1; j <= 3; j++) {
      scanf(" %c", &ch);
      f.a[i][j] = ch - '0';
    }
  q.push({f, 0});
  while (!q.empty()) {
    x = q.top();
    q.pop();
    if (!h(x.a)) {  // 判断是否与标准矩阵一致
      printf("%d\n", x.t);
      return 0;
    }
    for (int i = 1; i <= 3; i++)
      for (int j = 1; j <= 3; j++)
        if (!x.a.a[i][j]) fx = i, fy = j;  // 查找空格子(0号点)的位置
    for (int i = 0; i < 4; i++) {  // 对四种移动方式分别进行搜索
      int xx = fx + dx[i], yy = fy + dy[i];
      if (1 <= xx && xx <= 3 && 1 <= yy && yy <= 3) {
        swap(x.a.a[fx][fy], x.a.a[xx][yy]);
        if (!s.count(x.a))
          s.insert(x.a),
              q.push({x.a, x.t + 1});  // 这样移动后,将新的情况放入搜索队列中
        swap(x.a.a[fx][fy], x.a.a[xx][yy]);  // 如果不这样移动的情况
      }
    }
  }
  return 0;
}

\(k\)
短路

按顺序求一个有向图上从结点
\(s\)
到结点
\(t\)
的所有路径最小的前任意多(不妨设为
\(k\)
)个。

解题思路:

很容易发现,这个问题很容易转化成用
\(A^*\)
算法解决问题的标准程式。

初始状态为处于结点
\(s\)
,最终状态为处于结点
\(t\)
,距离函数为从
\(s\)
到当前结点已经走过的距离,估价函数为从当前结点到结点
\(t\)
至少要走过的距离,也就是当前结点到结点
\(t\)
的最短路。

就这样,我们在预处理的时候反向建图,计算出结点
\(t\)
到所有点的最短路,然后将初始状态塞入优先队列,每次取出
\(f(x) = g(x) + h(x)\)
最小的一项,计算出其所连结点的信息并将其也塞入队列。当你第
\(k\)
次走到结点
\(t\)
时,也就算出了结点
\(s\)
到结点
\(t\)

\(k\)
短路。

由于设计的距离函数和估价函数,每个状态需要存储两个参数,当前结点
\(x\)
和已经走过的距离
\(v\)

我们可以在此基础上加一点小优化:由于只需要求出第
\(k\)
短路,所以当我们第
\(k + 1\)
次或以上走到该结点时,直接跳过该状态。因为前面的
\(k\)
次走到这个点的时候肯定能因此构造出
\(k\)
条路径,所以之后再加边更无必要。

参考代码:

#include<algorithm>
#include<cstdio>
#include<cstring>
#include<queue>
using namespace std;
const int maxn = 5010;
const int maxm = 400010;
const double inf = 2e9;
int n, m, k, u, v, cur, h[maxn], nxt[maxm], p[maxm], cnt[maxn], ans;
int cur1, h1[maxn], nxt1[maxm], p1[maxm];
double e, ww, w[maxm], f[maxn];
double w1[maxm];
bool tf[maxn];

void add_edge(int x, int y, double z) {  // 正向建图函数
  cur++;
  nxt[cur] = h[x];
  h[x] = cur;
  p[cur] = y;
  w[cur] = z;
}

void add_edge1(int x, int y, double z) {  // 反向建图函数
  cur1++;
  nxt1[cur1] = h1[x];
  h1[x] = cur1;
  p1[cur1] = y;
  w1[cur1] = z;
}

struct node {  // 使用A*时所需的结构体
  int x;
  double v;

  bool operator<(node a) const { return v + f[x] > a.v + f[a.x]; }
};

priority_queue<node> q;

struct node2 {  // 计算t到所有结点最短路时所需的结构体
  int x;
  double v;

  bool operator<(node2 a) const { return v > a.v; }
} x;

priority_queue<node2> Q;

int main() {
  scanf("%d%d%lf", &n, &m, &e);
  while (m--) {
    scanf("%d%d%lf", &u, &v, &ww);
    add_edge(u, v, ww);   // 正向建图
    add_edge1(v, u, ww);  // 反向建图
  }
  for (int i = 1; i < n; i++) f[i] = inf;
  Q.push({n, 0});
  while (!Q.empty()) {  // 计算t到所有结点的最短路
    x = Q.top();
    Q.pop();
    if (tf[x.x]) continue;
    tf[x.x] = true;
    f[x.x] = x.v;
    for (int j = h1[x.x]; j; j = nxt1[j]) Q.push({p1[j], x.v + w1[j]});
  }
  k = (int)e / f[1];
  q.push({1, 0});
  while (!q.empty()) {  // 使用A*算法
    node x = q.top();
    q.pop();
    cnt[x.x]++;
    if (x.x == n) {
      e -= x.v;
      if (e < 0) {
        printf("%d\n", ans);
        return 0;
      }
      ans++;
    }
    for (int j = h[x.x]; j; j = nxt[j])
      if (cnt[p[j]] <= k && x.v + w[j] <= e) q.push({p[j], x.v + w[j]});
  }
  printf("%d\n", ans);
  return 0;
}

注:对于
\(k\)
短路问题,原题已经可以构造出数据使得
\(A^*\)
算法无法通过,故本题思路仅供参考,
\(A^*\)
算法非正解,正解为可持久化可并堆做法。

参考资料与注释

\(^1\)
: 此处的
\(h\)
意为
\(\text{heuristic}\)
。详见
启发式搜索 - 维基百科

\(A^* \text{search algorithm - Wikipedia}\)
的 Bounded relaxation 一节。

SSE

SSE(Server-Sent Events)是一种用于实现服务器主动向客户端推送数据的技术,它基于 HTTP 协议,利用了其长连接特性,在客户端与服务器之间建立一条持久化连接,并通过这条连接实现服务器向客户端的实时数据推送。

Server-Sent Events (SSE) 和 Sockets 都可以用于实现服务器向客户端推送消息的实时通信,差异对比:
SSE:

优点:
使用简单,只需发送 HTTP 流式响应。
自动处理网络中断和重连。
支持由浏览器原生实现的事件,如 "error" 和 "message"。

缺点:
单向通信,服务器只能发送消息给客户端。
每个连接需要服务器端的一个线程或进程。

Socket:

优点:
双向通信,客户端和服务器都可以发送或接收消息。
可以处理更复杂的应用场景,如双向对话、多人游戏等。
服务器可以更精细地管理连接,如使用长连接或短连接。

缺点:
需要处理网络中断和重连,相对复杂。
需要客户端和服务器端的代码都能处理 Socket 通信。
对开发者要求较高,需要对网络编程有深入了解。

SSE使用场景:

使用场景主要包括需要服务器主动向客户端推送数据的应用场景,‌如AI问答聊天、实时新闻、‌股票行情等。

案例

服务端基于springboot实现,默认支持SSE;
Android客户端基于OkHttp实现,同样也支SSE;

服务端接口开发

SSEController.java

package com.qxc.server.controller.sse;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;


@RestController
@RequestMapping("/sse")
public class SSEController {
    Logger logger = LoggerFactory.getLogger(SSEController.class);
    public static Map<String, SseEmitter> sseEmitters = new ConcurrentHashMap<>();

    /**
     * 接收sse请求,异步处理,分批次返回结果,然后关闭SseEmitter
     * @return SseEmitter
     */
    @GetMapping("/stream-sse")
    public SseEmitter handleSse() {
        SseEmitter emitter = new SseEmitter();
        // 在新线程中发送消息,以避免阻塞主线程
        new Thread(() -> {
            try {
                for (int i = 0; i < 10; i++) {
                    Map<String, Object> event = new HashMap<>();
                    String mes = "Hello, SSE " + (i+1);
                    event.put("message", mes);
                    logger.debug("emitter.send:  "+mes);
                    emitter.send(event);
                    Thread.sleep(200);
                }
                emitter.complete(); // 完成发送
            } catch (IOException | InterruptedException e) {
                emitter.completeWithError(e); // 发送错误
            }
        }).start();
        return emitter;
    }

    /**
     * 接收sse请求,异步处理,分批次返回结果,并存储SseEmitter,可通过外界调用sendMsg接口,继续返回结果
     * @param uid 客户唯一标识
     * @return SseEmitter
     */
    @GetMapping("/stream-sse1")
    public SseEmitter handleSse1(@RequestParam("uid") String uid) {
        SseEmitter emitter = new SseEmitter();
        sseEmitters.put(uid, emitter);
        // 在新线程中发送消息,以避免阻塞主线程
        new Thread(() -> {
            try {
                for (int i = 10; i < 15; i++) {
                    Map<String, Object> event = new HashMap<>();
                    String mes = "Hello, SSE " + (i+1);
                    event.put("message", mes);
                    logger.debug("emitter.send:  "+mes);
                    emitter.send(event);
                    Thread.sleep(200); // 每2秒发送一次
                }
            } catch (IOException | InterruptedException e) {
                emitter.completeWithError(e); // 发送错误
            }
        }).start();
        return emitter;
    }

    /**
     * 外界调用sendMsg接口,根据标识获取缓存的SseEmitter,继续返回结果
     * @param uid 客户唯一标识
     */
    @GetMapping("/sendMsg")
    public void sendMsg(@RequestParam("uid") String uid) {
        logger.debug("服务端发送消息 to " + uid);
        SseEmitter emitter = sseEmitters.get(uid);
        if(emitter != null){
            new Thread(() -> {
                try {
                    for (int i = 20; i < 30; i++) {
                        Map<String, Object> event = new HashMap<>();
                        String mes = "Hello, SSE " + (i+1);
                        event.put("message", mes);
                        logger.debug("emitter.send:  "+mes);
                        emitter.send(event);
                        Thread.sleep(200); // 每2秒发送一次
                    }
                    emitter.send(SseEmitter.event().name("stop").data(""));
                    emitter.complete(); // close connection
                    logger.debug("服务端主动关闭了连接 to " + uid);
                } catch (IOException | InterruptedException e) {
                    emitter.completeWithError(e); // error finish
                }
            }).start();
        }
    }
}

代码定义了3个接口,主要实现了两个功能:
stream-sse 接口

用于模拟一次请求,批次返回结果,然后结束SseEmitter;

stream-sse1接口 & sendMsg接口

用于模拟一次请求,批次返回结果,缓存SseEmitter,后续还可以通过sendMsg接口,通知服务端继续返回结果;

客户端功能开发

Android客户端依赖OkHttp:

implementation 'com.squareup.okhttp3:okhttp:4.9.1'
implementation("com.squareup.okhttp3:okhttp-sse:4.9.1")

布局文件:activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <TextView
        android:id="@+id/tv"
        android:layout_above="@id/btn"
        android:layout_centerHorizontal="true"
        android:text="--"
        android:lines="15"
        android:gravity="center"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_margin="20dp"/>

    <Button
        android:layout_width="200dp"
        android:layout_height="50dp"
        android:id="@+id/btn"
        android:text="测试普通接口"
        android:layout_centerInParent="true"/>

    <Button
        android:layout_width="200dp"
        android:layout_height="50dp"
        android:id="@+id/btn1"
        android:layout_below="@id/btn"
        android:text="sse连接"
        android:layout_centerInParent="true"/>

    <Button
        android:layout_width="200dp"
        android:layout_height="50dp"
        android:id="@+id/btn2"
        android:layout_below="@id/btn1"
        android:text="sse连接,携带参数"
        android:layout_centerInParent="true"/>
</RelativeLayout>

MainActivity.java

package com.cb.testsd;

import android.app.Activity;
import android.os.Bundle;
import android.view.View;
import android.widget.Button;
import android.widget.TextView;

import java.util.concurrent.TimeUnit;

import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import okhttp3.internal.sse.RealEventSource;
import okhttp3.sse.EventSource;
import okhttp3.sse.EventSourceListener;

public class MainActivity extends Activity {
    Button btn;
    Button btn1;
    Button btn2;
    TextView tv;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        btn = findViewById(R.id.btn);
        btn1 = findViewById(R.id.btn1);
        btn2 = findViewById(R.id.btn2);
        tv = findViewById(R.id.tv);

        btn.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                new Thread(new Runnable() {
                    @Override
                    public void run() {
                        testDate();
                    }
                }).start();
            }
        });
        btn1.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                new Thread(new Runnable() {
                    @Override
                    public void run() {
                        sse();
                    }
                }).start();
            }
        });
        btn2.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                new Thread(new Runnable() {
                    @Override
                    public void run() {
                        sseWithParams();
                    }
                }).start();
            }
        });
    }

    private void testDate(){
        OkHttpClient client = new OkHttpClient.Builder()
                .connectTimeout(10, TimeUnit.SECONDS)   // 建立连接的超时时间
                .readTimeout(10, TimeUnit.MINUTES)  // 建立连接后读取数据的超时时间
                .build();
        Request request = new Request.Builder()
                .url("http://192.168.43.102:58888/common/getCurDate")
                .build();
        okhttp3.Call call = client.newCall(request);
        try {
            Response response = call.execute(); // 同步方法
            if (response.isSuccessful()) {
                String responseBody = response.body().string(); // 获取响应体
                System.out.println(responseBody);
                tv.setText(responseBody);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    void sse(){
        Request request = new Request.Builder()
                .url("http://192.168.43.102:58888/sse/stream-sse")
                .addHeader("Authorization", "Bearer ")
                .addHeader("Accept", "text/event-stream")
                .build();

        OkHttpClient okHttpClient = new OkHttpClient.Builder()
                .connectTimeout(10, TimeUnit.SECONDS)   // 建立连接的超时时间
                .readTimeout(10, TimeUnit.MINUTES)  // 建立连接后读取数据的超时时间
                .build();

        RealEventSource realEventSource = new RealEventSource(request, new EventSourceListener() {
            @Override
            public void onEvent(EventSource eventSource, String id, String type, String data) {
                System.out.println(data);   // 请求到的数据
                String text = tv.getText().toString();
                tv.setText(data+"\n"+text);
                if ("finish".equals(type)) {    // 消息类型,add 增量,finish 结束,error 错误,interrupted 中断

                }
            }
        });
        realEventSource.connect(okHttpClient);
    }

    void sseWithParams(){
        Request request = new Request.Builder()
                .url("http://192.168.43.102:58888/sse/stream-sse1?uid=1")
                .addHeader("Authorization", "Bearer ")
                .addHeader("Accept", "text/event-stream")
                .build();

        OkHttpClient okHttpClient = new OkHttpClient.Builder()
                .connectTimeout(10, TimeUnit.SECONDS)   // 建立连接的超时时间
                .readTimeout(10, TimeUnit.MINUTES)  // 建立连接后读取数据的超时时间
                .build();

        RealEventSource realEventSource = new RealEventSource(request, new EventSourceListener() {
            @Override
            public void onEvent(EventSource eventSource, String id, String type, String data) {
                System.out.println(data);   // 请求到的数据
                String text = tv.getText().toString();
                tv.setText(data+"\n"+text);
            }
        });
        realEventSource.connect(okHttpClient);
    }
}

效果测试

调用stream-sse接口

服务器分批次返回了结果:

调用stream-sse1接口

服务器分批次返回了结果:

通过h5调用sendMsg接口,服务端继续返回结果: