reinterpret_cast
;根据它在 C++ Reference 上的描述,它纯粹是编译时指令,指示编译器将表达式 视为如同具有目标类型 类型一样处理。通过这种操作可以绕过 C++ 的类型系统,随意解释一块内存上的数据,实在是太酷啦!那么古尔丹,代价是什么呢?—— 即使是面对这样的质问,很多人想必也会不以为意:不就是保证内存安全吗?我都操作指针了,这种事情对我来说不是小菜一碟?然而很不幸的是,很多事情的运作并没有这么简单;请看下面的例子:
1 | int foo(float *f, int *i) { |
函数 foo
十分简单,想必学过 C++ 的人一眼就可以得出答案 —— 这不就是输出 0
吗?然而在某些编译器,比如某个版本的 clang
下,如果开启了 -O3
优化,它的输出竟然是 1
…… 只能说确实有点急。
但是如果只是因为这个就心急火燎,直接判定这个 clang
出 bug 了倒也大可不必。实际上从 C++ 的标准来看,在某些特定的场合,它还真的应该是 1
;只是要解释这背后的原因,就要涉及到一个比较隐蔽的概念,也就是所谓的严格别名规则了。
在了解严格别名规则之前,你可能还会需要了解以下冷门概念。
兼容类型是 C 特有的概念。作为比 C++ 还要底层的语言,C 完全遵循“古法”编译,有着纯粹的结构体,代码本身更能直接反馈底层的结构…… 这使得它的 ABI 比较简单。
C 的兼容类型解决了不同的翻译单元以及一些其他场合中,对象类型的同一性的认定问题。在不同翻译单元关于同一个对象的不同定义、函数形参实参的绑定、通过左值访问对象的过程中,实际上并不严格要求两者类型的严格一致 —— 它们只需要具有兼容的类型,那么就是合法的。
对于两个类型是否兼容的判断,可以遵循以下的要求进行:
char
既不与 signed char
兼容,也不与 unsigned char
兼容这个方面,C++ 的要求会严格许多 —— C++ 要求类型必须完全一致,即使两个类型“翻译到底层后布局一致”,如果不是同一个类型,那么也是非法的。此外,C++ 有相似类型的概念,但是和这里的作用相去甚远。
有效类型是 C 特有的概念。每个 C 对象都有一个“有效类型”,这个类型是更本质的,和指向该对象所在内存的指针类型未必相同。通过它可以决定通过某些左值对特定对象的访问是否合法,从而有了严格别名使用规则。
判断对象的有效类型十分简单;如果对象是由声明创建的,那么声明的类型就自然成为了对象的有效类型;否则对象是由内存分配函数“创建”的,那么它并不会立即获得有效类型,直到首次通过非字符的左值写入或者使用内存复制函数写入时,它获得这些复制的源的类型作为其有效类型。
由此可见,即使在 C 这么“原始”的底层语言中,内存和对象之间也有着明确的界限。
动态类型是 C++ 的概念。它类似 C 的有效类型的概念,但是它出现在一门可以是 OOP 的语言中就显得更加有说服力一些。它的完整定义如下:
一个很常见的例子:将一个派生类对象的地址赋值给一个指向基类对象的指针,那么该指针的动态类型是派生类,而静态类型是基类。这是作为一个实现了运行时多态的编程语言必须具备的基础设施。除了这种场合之外,将动态类型类比 C 的有效类型也未尝不可 —— 毕竟 C++ 明确要求一块内存必须先被初始化后才能够合法使用,一块没有类型的内存块在初始化中获得了动态类型也不是什么情理之外的事情。
restrict
关键字restrict
关键字是 C 独有的。它只能够用来限定 C 的对象类型的指针或者数组,用来标识在当前作用域内,该指针是访问它所指向的内存块的唯一合法方法 —— 即不应该存在其他指向该指针所指的内存块的指针。然而语言层面上 restrict
并没有严格的约束,因此需要程序员来保证它的运用合乎语义。
restrict
的意义是,编译器可以利用这个信息进行特定的优化;然而编译器也可以选择无视这个信息,而一个合乎语义的程序不会因此产生表现上的差异;但是如果程序员没有保障语义的正确性,那么程序可能 UB。
此外,虽然 C++ 标准不再包含 restrict
关键字,但是作为常用工具,大多数的编译器都以 __restrict
或者是 __restrict__
的形式进行了拓展;然而使用它们会使得你的代码和编译器强相关,请自行斟酌。
严格别名规则限制了可以用作实质是某类型的对象的别名的类型 —— 简单的说就是规定了合法的类型双关。在介绍严格别名规则之前还需要介绍什么是别名,什么是类型双关。
当通过多个左值访问同一个对象的时候,那么这些左值就是这个对象的别名。在 C/C++ 的场合,多个指向同一块内存的指针就满足了这种情况,这些指针就构成了这块内存中存放的对象的别名。
绕过 C/C++ 的类型系统,将一个类型的对象(所占用的内存块)重新解释为另一个不同的类型,就产生了所谓的类型双关(Type Punning)—— 从定义上来看,这简直就是 reinterpret_cast
在做的事情;实际上,获得一个对象的指针,然后使用 C 风格的强制转换或者 reinterpret_cast
转换为另一个类型后再访问,就是产生类型双关最常见的方法。
虽然这看起来十分丑陋,但是作为一门几乎直接在和底层打交道的编程语言,在很多需要直接访问或操作对象的底层表示的场合,类型双关十分有用;就比如序列化、网络传输、驱动开发中,会大量使用到这种特性。
若要通过类型为 T2
的左值表达式访问具有有效类型的 T1
的对象(即将 T2
类型的左值表达式看作是 T1
类型的对象的别名,这显然可能产生类型双关),则必须满足以下条件之一
T2
和 T1
兼容
T2
是和 T1
兼容的类型的 cvr 限定版本,或者是有符号/无符号版本
T2
是聚合体/联合体,并且包含一个和 T1
满足上述要求的成员
T2
是字符类型;即 char
、signed char
、unsigned char
中的一个
否则,程序 UB。
可以注意到条件 3 的存在允许了通过联合体的非活跃成员(和活跃成员之间的类型差异)进行类型双关;而且即使在类型双关可以发生的场合, C 独有 restrict
限定符也可用于显式指示指针指向的对象不包含其他别名,从而允许编译器进行大胆的优化。
如果要通过类型为 TAlias
的(泛左值)别名读取/修改动态类型为 TDynamic
的对象的值的时候(即 TAlias
作为 TDynamic
对象的别名而可能产生的类型双关),需要满足以下条件之一
TAlias
和 TDynamic
是相似的TAlias
是 TDynamic
的有 cv 的、有符号/无符号的变体TAlias
是 std::byte
、char
或者 unsigend char
—— 按字节处理对象占用的内存总是合法的否则,程序 UB。
需要注意到,在 C 中被视为合法的通过 union
进行的类型双关在 C++ 中是非法的 —— 即使绝大多数的编译器都会默许这种行为并且生成正确的代码;这可能是由于 C++ 的对象比 C 的对象(结构体等聚合体)的内存布局要更加复杂导致的。
通俗地总结 C/C++ 的严格别名规则,那就是任何类型的指针指向的区域,都不能与类型“无关”的指针指向的区域产生重叠;但是字符是这个规则允许的“例外” —— 似乎是底层语言最后的矜持?不过确实也并不难理解,绝大多数的硬件都是按照字节寻址,此时字节就是最小的单位,不会因为硬件差异而有所差别。
和 restrict
类似,严格别名存在允许编译器进行基于类型的别名分析(Type-Based Alias Analyze,TBAA):即编译器基于“通过某些(上述严格别名规则之外的情况)类型的泛左值读取的值,不会被通过不同类型的泛左值的写入所修改”的假设,得以进行一些优化。通常情况下编译器优化的依据很多,但是在复杂的代码中,编译器无法根据上下文判断指针指向的内存区域的重叠情况的时候,这就能作为依据使得编译器进行优化。
尽管这样看来,严格别名规则十分自洽,似乎也没什么问题;但是在需要用到违反它的类型双关的场合,它就可能使得编译器产生意料之外的代码…… 这里给出了一些例子。
Compiler Explorer:https://godbolt.org/z/1Mvhx1Kz7
代码:
1 | int foo(float *f, int *i) { |
这便是文章开头的那个例子。在了解了严格别名规则之后,解释它就并不困难了:foo
函数接受的两个指针显然不符合严格别名规则 —— 因此它们不应该指向重叠的内存,也就是说这两个指针“理应”不指向同一片内存。基于这个假设,编译器对 foo
函数的返回语句做了死代码优化:既然返回 *i
前,它指向的内存不会被修改,那么比起根据指针 i
访存,不如直接将用于赋值的值作为立即数返回;反汇编的结果也支持这种观点 ——
1 | foo(float*, int*): # @foo(float*, int*) |
可以看到这个函数体就是错误的 —— 就像我们的分析一样直接返回了立即数 1。不过实际上这种情况已经不太会出现在 2024 年的编译器上了毕竟还是得服务实际应用的,但是仍然需要意识到这是个会引发 UB 的写法。
Compiler Explorer:https://godbolt.org/z/gjh5hB
代码:
1 | void Store128(void *dst, const void *src) { |
某位大牛在他的这篇回答中就这个问题对 gcc 团队直球辱骂,但是在详细分析了这个案例之后只能说好骂。
在这段代码中,我们尝试在函数 Store128
中使用类型为 uint32_t
的别名修改有效类型为 float
的内存,这显然违背了严格别名规则 —— 所以 uint32_t
指针和 float
数组首指针不应该指向同一片内存…… 到这里还没什么问题,但某个并不算老的(9.x)gcc 在经过一番优化之后,产生的代码的输出却是和 A
和 B
都毫无关联的 0 -0 0 0
…… 怎么会这样?
查看产生的反汇编之后,更离谱的事情发生了 —— 编译器甚至没有为局部变量数组 A
/B
生成初始化代码,也难怪结果和 A
和 B
都毫无关联。但是这到底是为什么呢?就算是死代码消除,可是明明后面就有 printf
对 B
的使用啊?只能说大概 gcc 确实是出 bug 了 —— 它的好兄弟 clang 和 msvc 都不会有这个问题。
实际上,如果解决了严格别名规则的问题,也就是使用字符类型 uint8_t
来进行这个操作,就一切正常了;但是毕竟是编译器 bug,只能说希望一辈子都不要遇到== 而且逐字符的赋值,产生的汇编真的就是直接的 mov BYTE PTR
…… 但如果使用 int
理应能产生数量更少的 mov DWORD PTR
啊!
所以就这个案例来说,只能说大牛好骂 —— 为了一个根本优化不到 1% 的性能的语言特性,不做任何警告,静默进行这么大的优化,甚至还实现出 bug 了;只能说一般开发者根本无力排查。
Compiler Explorer:https://godbolt.org/z/36oKz85od
代码:
1 |
|
但是,TBAA 确实有时有奇效。上述的代码并没有产生什么意料之外的输出 —— 两个版本的 unpack3bit
函数,一个版本是被声明为 T
的成员函数 —— 它的两个参数被作为数据成员储存在 T
中,另一个版本是简单函数;两种写法都能得到理想的结果,也似乎没有什么差别 —— 但是编译之后非成员函数版本比成员函数版本快了足足 15%,这又是怎么回事呢?
查看它们生成的代码的反汇编之后就找到了答案:成员函数版本每次操作都会额外生成一条多余的指令
1 | T::unpack3bit(int): |
为什么在成员函数版本中,编译器就不能判断 target
是不变的呢?虽然很隐晦,但是这实际上也和严格别名规则相关:target
对于 T
而言都是数据成员 —— 也就是说它实际上是 this->target
,它的值依赖 this
指针指向的对象;而它的类型是 unsigned char
,这满足了严格别名规则,使得它可以成为任何指针的别名候选,这些候选中自然也包括了 this
;因此,编译器认为它们可能指向同一片内存,而指针 this->target
的地址就储存在 this
指针指向的内存区域中,这意味着通过 this->target
的修改可能修改它自身!
实际上在极端如 this->target = this
的场合,通过 target
修改数据,还真的就修改了 this
的数据块,从而修改它自身。因此,在 target
可能被改变的前提下,为了保证在极端情况下的正确性,编译器必须在每次赋值前都会生成重新加载 taeget
的代码,才符合程序的语义。
知道问题所在之后,解决就很容易了;主要有三个思路:
在函数体内用局部变量缓存一次 target
(像代码中注释的部分一样) —— 这其实已经改变了语义,它使得每次赋值的指针 target
只是调用函数是 this->target
的值;这自然是不会改变的,无需重新加载
修改指针的类型为 uint16_t
或者其他数值类型 —— 这样就使得它破坏了严格别名规则,编译器会认为它和 this
不再会指向同一块内存,从而通过 target
的修改不再会影响 this->target
,尽管这对语义变动更大
使用 restrict
显式地限定 this
—— 这样相当于指示了编译器:它的成员只能通过它修改,而不会被什么其他的指针修改,比如 this->target
;但是这么做的前提是你的编译器为 C++ 拓展了这个关键字
所以,虽然有性能影响,但是还是得骂 C++ 标准委员会 —— 这三种方法,甚至只有看似意义不明的方法一,在最大限度的保留了语义的前提下实现了优化;方法二三不是大动干戈,就是编译器依赖,只能说放那么多 policy()
在见识了一众基于严格别名规则的例子之后,刚才还眉清目秀,有点道理的严格别名规则突然就变的面目可憎了起来,不由让人感叹:你妈的,为什么?可能在此基础上还有一句长叹:它都这样了,我只能选择顺从…… 那么怎么顺从呢?
以下内容均为作者暴论,仅供参考。
为什么严格别名规则这么愚蠢?我个人认为可以归纳为以下四点:
1
是合理的,在今天你也很难找到真的能老实产生类似结果的编译器了;实际上编译器的优化是依据多个要素的,也是为实践服务的,几乎不太会对这种问题生成 UB 的代码;更何况还有上述 gcc 魅力时刻这种纯反面教材……reinterpret_cast
到底还是不是“重解释”了?结合它必须要遵循严格别名规则才是符合语义的限制,我们惊喜的发现它原来就是个小丑!你大可以继续在你的代码里继续使用它,但是它比 C 风格强制类型转换的好处就是出了 bug 方便你全局搜索,然后再一处一处地分析思考:它是不是违背内啥啥规则了?编译器是不是在这里会产生抽象代码?简而言之,就现在的应用而言,C/C++ 仍然是更多地作为和底层打交道的语言被使用;它的很多用户比起它的语义的现代性和编译器那甚至可以说是微不足道的优化而言,更在意它是否能直观地,准确地,如实地反应它将要被编译成的汇编指令;但是委员会似乎不这么想…… 这某种层面上也算是学术界和生产界的认知鸿沟?
那么我们要怎么进行类型双关呢?可能有如下思路:
fno-strict-aliasing
选项,然后继续 reinterpret_cast
—— 眼不见为净,反正蚊子腿的性能优化,不要也罢!memcpy
:分配一块新的内存,然后使用 memcpy
复制一份 —— 语义上是正确的,两种不兼容的别名分别指向了互不重叠的原件和副本,但是创建副本就是两倍的内存开销!union
:C++ 标准不是说这非法吗?那就 extern "C"
!再不济 —— 相信的心就是你的魔法!你要相信现在的编译器都十分人性化,不会因为这种微不足道的错误就发癫的!bit_cast
:然而只能进行编译期就能确定大小相同的两个类型的转换 —— 本质上是 memcpy
的语法糖 + 编译期的拓展,但是运行时会产生副本还是一样的,更何况这糖也不甜啊!总上说述,只能说千言万语汇成一个字 —— 摆;不然还能怎么办呢?有没有董哥教教==
std:sort
然后熟练地按下 Tab
键,如果不出意外,它应该会为我自动补全补全列表第一行的 begin..end
;但是毫无征兆的黄色波浪线为这本来理所应当的日常染上了非日常的色彩…… 不是这你也能警告的?一看提示,竟然是推荐我用一个叫做 ranges::sort
的 API,一看调用签名,好家伙不就是省去了 begin..end
吗?这部分还是 IDE 已经帮我写了;但是一看这是个 C++ 20 的新特性就有点绷不住了,所以去学了。
就像编译器的提示一样,比起它是个什么东西,可能大火还是会更在意它会对我们的编程造成什么改变;假设我们现在要对包含了 1~n
的数组的每个元素进行乘加操作,然后再从中选出一部分感兴趣的元素,最后再打印前五个元素;那么在没有范围库的 C++ 早期版本中就得这么写:
1 | auto my_func(const set<int> &interested, int n, int k, int b, int m = 5) { |
很麻烦!写了大量的 begin..end
且不说,甚至还要产生中间变量,实在是有够蠢的;有的人可能认为这是瞎几把用 STL 算法导致的,毕竟如无必要勿增实体;那么我们用最原始的 C++ 循环来进行这个过程:
1 | auto my_func(const set<int> &interested, int n, int k, int b, int m = 5) { |
看起来确实简洁了不少,但是如果初始数组不是硬编码的话,还是没有避免中间变量的产生;如果有很多这种操作呢?为每一个操作编写一个硬编码的函数吗?
但是现在我们有了范围库,所以它的全新版本是下面这样的:
1 | auto my_func(const set<int> &interested, int n, int k, int b, int m = 5) { |
看起来用的还是 C++ 算法库里的那些东西,但是给人的感觉完全不一样了!新的代码不仅是看起来更加简洁明了可读易懂,也完全避免了中间变量的产生;而且,都已经这么简洁的代码了,甚至还可以重用!如果我现在需要对一个数组先选出一部分元素,再进行上述操作的话,第一个老代码尚且还可以再增加一个中间变量,而第二个代码就完全不太能用了,得重新写一个新的硬编码函数了…… 但是在这里我们只需要:
1 | auto my_operate = views::transform([=](int i) { return i * k + b; }) |
上述代码几乎没有做什么伤筋动骨的修改,就又在不创建中间变量不损失可读性的情况下快速的实现了新版本。这就是范围库给我骄傲的资本!
从上文的例子中我们可以看到,使用传统 C++ 基于迭代器的算法描述这一过程,不仅会产生不必要的中间变量,代码本身也难以阅读,更不具有什么迁移性;而硬编码则更是重量级;因此,为了更加优雅、清晰地描述算法,并尽量避免中间对象的生成,还要支持逻辑的重用,摆脱传统 C++ 的迭代器体系势在必行。
范围库,也就是 <ranges>
头文件。仅就 C++ 20 引入版本的范围库而言,它包含以下的组成部分:
范围的概念:范围表明了对象的可以被迭代的属性;范围以迭代器来指示开头,并以哨兵来指示结尾(哨兵的类型可能和迭代器不同);STL 中可迭代的容器都是范围。
访问范围的函数:一组用来从符合范围约束的对象中获取迭代器/哨位,或者是范围大小的方法。
视图类:视图是轻量的范围;它的构造/复制/移动/销毁的时间是常数,而和范围大小无关;视图通常不拥有元素所有权,并且可以被视图工厂或者是范围适配器工厂创建;它们被定义为 ranges::
下以 _view
结尾并实现了 view
的类。
范围相关类型的别名模版:一组用来获得范围中迭代器/哨位/大小的类型的别名模版。
范围适配器闭包工厂:范围适配器是修改范围来生成延迟计算的视图的闭包;除了可以和范围组合形成视图之外,范围适配器之间可以组合;产生这些闭包的函数被定义在 views::
下,被称为范围适配器工厂;每个函数都对应了一种上述的视图类。
视图工厂函数:生成简单视图(而非闭包)的函数;它们和范围适配器工厂一样是被定义在 views::
下的函数,并且存在对应的视图类;它们产生的视图通常也是延迟求值的。
范围上进行的受约束算法:一系列和 std::
下迭代器算法几乎完全相同的算法;不同的是它们受到概念强制约束,接受迭代器-哨位对或者是范围作为处理对象。
整个范围库最核心的部分就是范围的概念,它告别了传统 C++ 迭代器描述的容器-算法的结构,转而在新的更加语义化的范围体系里进行讨论;视图是整个标准库中除了 STL 容器之外的另一大实现了范围的类,为这个结构提供了可以操作的对象;而范围适配器则定义了这个体系的操作,它比起基于迭代器的算法更加语义安全,更加符合实际应用且写起来更方便整洁。
文章开头的例子就是对着三大块的应用:在范围概念的约束下,创建视图并且用范围适配器修改它,再对得到的结果进行遍历。
和传统 C++ 的迭代器区间类似,范围是使用一组迭代器-哨兵确定的左闭右开的可遍历的区间。在 C++ 20 中,范围是一个概念,也就是说可以说某个类或对象是一个范围,但是并不能直接实例化范围;实际上 STL 中可以迭代的容器都是范围。范围作为概念的语法定义如下所示:
1 | template< class T > |
也就是说,一个类如果实现了 begin
/end
方法或者是对标准库的 begin
/end
重载,那么它就是一个范围;这和之前就有的 range-based for
-循环是一样的;但是对于概念而言,语法上的要求并不是全部;在语义层面上,范围还要求这两个方法是常数时间的。
范围的定义如此简单,可能会令人产生疑惑?那么之前 STL 容器里定义的这个那个迭代器又算是什么呢?实际上范围确实还要有迭代器类型,但是比起之前基于迭代器的容器需要反复定义各种 cvr 限定的版本,在范围库中,只要定义了 begin
/end
,其他的版本都可以推导出来;范围库定义了一组获得它们的各种限定版本的类型别名。这些类型别名只基于两个类型:begin
的返回值被看作是迭代器类型,end
的返回值被看作是哨兵类型。
你可能已经注意到了,比起 STL 容器中使用一对迭代器表示区间,范围明确的将区间的两侧分成了不同的类型;且不论迭代器,哨位到底是什么呢?简单地来说,它是一个实现了至少单侧的和迭代器的 operator==
的对象,当这个判等时,说明迭代器已经到达了范围的边界;C++ 20 将这种概念形式化地实现为 std::sentinel_for
,所以可以说,对于迭代器 It
而言,它的哨兵 Se
应当满足了 sentinel_for<Se, It>
。
举一个最典型的例子,对于 C 风格的字符串,我们可以将字符串末尾的 \0
看作是哨兵,所以可以实现对于 C 风格字符串的迭代器,也就是 C 指针的哨位:
1 | struct c_string_npos { |
只要实现了两侧运算符的任意一个,sentinel_for<c_string_npos, char *>
就为 true
。
和迭代器定义的区间一样,范围也根据其迭代器类型的不同具有不同的类型。但是在这之外,还有一些场合除了对于迭代器的显式的语法要求,还有着更多的语义要求,就拓宽了范围的类型。因为这些额外的类型往往无法在语法层面上给出行之有效的定义,就需要程序员在符合语义的前提下,手动启用/关闭这些特性。
下面介绍一些在迭代器类型之外的不同类型的范围。
std::ranges::sized_range
除了所有范围都应当实现的常数时间的 begin
/end
方法之外,还要实现常数时间的 size
方法,并且确保通过 begin
/end
获得的迭代器和哨位之间可以作差。
如果类在语法层面上实现了 size
方法,但是该方法并不符合语义上的要求的场合(最经典的:比如 size
方法不是常数时间),程序员应当特化 disable_sized_range
来避免该类被识别为 sized_range
;如果还要特别说明 begin
/end
获得的迭代器-哨位之间不可作差,还要额外特化 disable_sized_sentinel_for
。
std::ranges::borrowed_range
这个概念要求从范围实例的 begin
/end
方法获取的迭代器的生存周期可以比范围实例本身更长;因此即使范围实例被销毁,使用它产生的迭代器也不应该遇到悬垂问题。在大多数时候,这种情况表明范围本身并不具有数据的所有权,因此范围本身的生命周期和迭代器的生命周期无关;但是这并不代表着使用来自 borrowed_range
的迭代器就可以不考虑这个问题,来自它的迭代器的安全仍然是程序员保证的。
在标准库中的 borrowed_range
并不多;在引入范围库之前,只有 string_view
和 span
满足它的约束 —— 这也是上文中说到的“大多数时候”;而考虑到范围库,subrange
、一部分工厂函数生成的简单视图以及部分范围适配器产生的视图也被看作是 borrowed_range
。
但是实际上,代码中的 borrowed_range
可能一点也不少。我们观察 borrowed_range
概念的语法实现:
1 | template<class R> |
可以看到有两层约束:首先 borrowed_range
显然必须得先是 range
;然后第二个条件就很有意思,它要求的是当 R
是一个左值引用或者用户手动开启了 enable_borrowed_range
;后者且不论,前者可能会一时让人有些诧异,但是仔细想想也确实是这么一回事 —— 左值引用并不具有实际对象的所有权,因此还是“大多数时候”!
为了在编译阶段避免将非借用范围传递给一个需要使用到 borrowed_range
性质的函数,标准库还提供了一个模版别名 borrowed_iterator_t
;它实际上是一个条件类型:它接受一个范围作为模版形参,当模版实参是一个 borrowed_range
时这个类型被定义为该范围的迭代器,否则则是占位类型 dangling
,对任意尝试解引用它的代码报编译错误。
视图是范围的细化概念,它要求实现它的类型是如果支持复制/移动/创建/销毁操作,那么这些操作应当可以常数时间内完成;一般来说,视图不占有其包含元素的所有权;由于操作的时间复杂度和数据的所有权显然不是语法层面的特征,因此需要用户在满足语义时手动开启;它的声明如下:
1 | template<class T> |
观察它的声明,可以再次确认视图是范围,并且在语法层面上支持移动操作;除此之外的语义部分,编译器无从感知,所以需要用户手动开启 enable_view
来保证语义的正确。
为方便实现,范围库还提供了空基类 view_base
和辅助声明的接口 view_interface
;任何继承自它们的类都相当于手动开启了 enable_view
。
std::ranges::view_interface
和空基类 view_base
不同,view_interface
不仅为派生类开启了 enable_view
,还可以辅助派生类定义作为范围最基本的 begin
/end
接口,并根据迭代器的类型,为派生类增加标准库风格的其他范围接口,并且这些接口无需程序员自行实现;因此它常常以 CRTP 的方式使用:
1 | class my_view : public std::ranges::view_interface<my_view> { |
根据迭代器类型的不同,view_interface
会提供不同的接口;具体的规则如下:
迭代器类别 | 对应范围 | 新提供的方法 |
---|---|---|
range | begin /end | |
input_iterator /output_iterator | input_range /output_range | |
forward_iterator | forward_range | empty /bool /size /front |
bidirectional_iterator | bidirectional_range | back |
random_access_iterator | random_access_range | operator[] |
contiguous_iterator | contiguous_range | data |
也就是说,通过 view_interface
,你只需要定义最基础的 begin
/end
,其他的这一系列方法都会由编译器自动生成,并且符合 C++ 标准库要求,无需手动实现;比如下面实现了一个对于 C 风格整数数组的视图类:
1 | struct my_view : public std::ranges::view_interface<my_view> { |
由于 C 指针是一个连续迭代器,指向一块连续的内存空间,因此上述表格中的所有方法都已经自动实现了。
std::ranges::subrange
最基本的范围只包含 range
/end
方法,相应的最简单的视图也只包含一组迭代器-哨位。和 view_interface
一类似,它也会根据迭代器的类型来提供不同的访问方法;但是这在 size
方法上却有所不同,可以参考声明:
1 | template< |
subrange
接受了三个模版参数,分别是迭代器类型、模版类型和一个枚举;前两个没什么好说的,但是这个枚举则决定了子范围是否实现了 size
:如果不能通过迭代器-哨位作差以常数时间地求出 size
,但是却通过这个枚举强制要求支持 size
,那么除了迭代器和哨兵之外,还会额外存储尺寸。
subrange
总是实现了 ranges::viewable_range
;并且当支持 size
时,还实现了 ranges::sized_range
,这也没什么问题,因为获得 size
确实只需要读出存储的尺寸就可以了,这当然是常数时间的;并且,总是可以将 subrange
看作一个 pair
来进行方便的解绑,获得其存储的迭代器和哨卫。
范围适配器代表着对于一个 range
内元素的操作,它往往也支持延迟求值,并且通常可以和其他的范围适配器进行组合;这种可以独立于具体的范围进行组合的特性使得它可以方便的重用逻辑,而延迟求值的特性则使得它不会产生中间变量,从而彻底解决了传统 STL 基于迭代器算法的问题 —— 也就是本文开头中的炫酷写法;甚至,之前因为众所周知的原因而没能实现的字符串 split
操作也因此有了加入标准库的曙光。
范围适配器闭包指的是接受 viewable_range
或者另一个范围适配器闭包的一元可调用物;接受前者时返回一个 view
,接受后者时返回一个新的组合后的闭包;并且还重载了运算符 |
作为调用的语法糖,使得组合范围适配器的语法相当简洁。形式化地说,范围适配器闭包满足如下性质:
C
且有 R
实现了 range
概念,则表达式 C(R)
和 R | C
等价C
和 D
,并且有 E = C | D
,那么 E
存储 C
和 D
的副本,并且对于任何实现了 range
概念的 R
,满足结合律:R | E
和 R | C | D
和 D(C(R))
等价标准库中的每个范围适配器都对应了一个位于 ranges::
下以 _view
结尾的视图类;将适配器应用到范围就可以得到对应适配器的视图,这些视图具有适配器对应的视图类;此外,这些类都实现了 view
和 viewable_range
以兼容下一次转换。
如果你翻阅 C++ 文档,你可能会发现在范围库中只能找到工厂函数以及对应的视图类的定义,但是完全找不到范围适配器闭包这个东西;甚至是广受好评的语法糖 |
,相关的类也没有一个实现了这个的重载;那么这个语法糖究竟是怎么实现的呢?
在 C++ 20 中,范围适配器闭包这一如此重要的概念只是被提出,而并没有被形式化的纳入标准中;也就是说和各种内部类型/内部对象一样,范围适配器闭包的实现因编译器而异;在简单地阅读了 Apple Clang、GCC 和 MSVC 的实现之后,这里将比较好懂的 Apple Clang 版本放在下面:
1 | template <class _Tp> |
可以清晰地看到,在上文形式化定义中的两种不同情况下的 operator |
的重载:在应用到范围上的时候,直接调用闭包并将范围作为参数传入;在组合两个闭包的时候,发生的并不是调用,而是将两个函数进行组合。组合的类型在 MSVC/GCC 中声明了新的内部类型,看起来就太麻烦了。
不过在 C++ 23 中,这一概念已经被纳入标准库:std::ranges::range_adaptor_closure
这一小节的标题相比没有必要叫“工厂函数是什么?”吧?毕竟这算是一个很经典的 OOP 概念:工厂函数是用来产生对象的函数;C++ 范围库中包含了两种工厂函数,它们都位于 ranges::views::
下,分别产生简单的视图对象和范围适配器闭包。
简单的视图对象指的是通过参数简单描述的,并且支持延迟求值的视图,最经典的就是自增视图 iota
:只需要一个开始元素就可以声明一个无限长的自增序列 —— 当然自增并没有实际进行无限次,只有当需要访问视图的时候才会逐个自增计算每个元素。
范围适配器以及它的闭包的定义已经在上一小节讲过了。而有些常用的 C++ 序列算法在这里被实现为了范围适配器,比如 filter
啊 transform
这些;在别的高级语言里这些都理所应当的是数组的方法,但是在传统 C++ 中却为了迭代器将它们和容器分开 —— 现在它们又理所应当地结合在一起了!
和范围适配器一样,每个简单视图工厂函数都对应了一个位于 ranges::
下以 _view
结尾的视图类,并且该类就是对应工厂函数的返回类型;当然,这些视图类都实现了 view
和 viewable_range
(它们本来就是视图!)以兼容范围适配器的转换。
范围算法又称为是受到范围概念约束的算法;本质上它是 C++ 算法库中的迭代器算法对于范围的实现,将范围这一整套概念融于其中来约束算法的行为,从而对它们的调用更容易得到语义上的正确。当然,从语法的角度来说也省去了 begin..end
的麻烦,毕竟不是所有人都在用 CLion()
部分算法也有着不同的表现,就比如文章开头提到的 ranges::sort
函数:它的迭代器版本 std::sort
接受了为了支持并行计算的执行策略,而范围库里的版本则另外接受 Proj
参数,用于从元素中获得需要用于比较的部分(默认值为 std::identity
,就是返回自身的一元函数),因此可以认为范围库中的版本实际上更加贴近实际使用,毕竟为了按照 ID 数字大小排序就写一个排序器还是有些繁琐了。
为什么范围适配器的语法糖这么美丽?这就是函数式编程给你带来的自信吗!
作业代码参见: ymd45921 / oneapi-homework
在使用 OneAPI 之前,首先我们需要知道一些与之相关的概念;不然编程练习也无从谈起:
综上所述,在异构计算领域中,万能的 Khronos Group 有 OpenCL 这一低级标准和 SYCL 这一高级抽象,然而都没有实现;而比较广泛的异构计算的设备是 GPU,又因为老黄猖獗(不),所以 CUDA 成为了实际上异构计算常用的技术 —— 它是用于在 N 卡上进行高性能计算的专用编程模型/语言;为了打破这一局势,Intel 搞出了一个叫做 OneAPI 的东西,它包含了一个算是 SYCL 实现的语言 DPC++,一些常用的异构开发库,以及一系列用于调优和方便开发的实用工具(比如 CUDA 到 DPC++ 的迁移工具)。
和 Vulkan 中主机和 GPU 一样,在异构计算中也有着这样的主机-设备模型:主机先选择要派发作业的设备,然后将已经转换成设备端的代码的核代码发送到设备的作业队列中,然后的作业就由设备完成,主机则去干干别的,直到设备完成作业通知主机进行下一步的操作;这之中关键的概念有 ——
sycl::device_selector
并重载 operator()
方法,该方法为作为参数传入的设备打分;在那之后,设备选择器可以是一个任意可调用物,作用依然是打分。至于到了 SYCL 代码中,一个这样的模型就如下所示:
这段代码中,主机首先先通过 sycl::buffer
来管理内存,然后选择设备(这里省略了)创建命令队列,然后使用 submit
函数将任务提交到队列中;在提交任务的 Lambda 表达式中,首先通过 accessor
说明作业和数据的关联(这是作业相当重要的部分,DPC++ 编译器会根据这个来决定作业在设备上运行的顺序,来保证得到的结果是可预测的),然后再通过 parallel_for
函数发起并行 —— 这里的 Lambda 表达式就是核函数了,它会像 Shader 那样被加速设备的每个单元并行执行以加速计算。
正因为有明确的 Host-executed Scope 和 Device-executed Scope,才使得这两段代码出现在一个文件里成为了可能;但是在编译阶段,编译器仍然会将这两部分代码分开处理,并且根据 Command 的信息对在设备上的运行进行优化以尽可能提高并行度 —— 这也自然的导致了,在设备上执行的核代码的 C++ 必然是受到了限制的;毕竟加速硬件大规模的代价就是泛用度的降低嘛。
因为涉及到了作业的递交,所以就也会出现主机端的数据和设备端的数据的问题;对于 SYCL 而言,主机端的数据有两种模型:一是上面用到的更接近数组声明的 Buffer & Accessor 模型 —— Buffer 将持有内存的管理权,而 Accessor 声明作业对内存的访问权(OneAPI 大概会在设备上开辟一片这样的空间将数据传输过去?不过这里的细节我就不太了解了)—— 但是一旦一块主机内存交给 Buffer 管理(比如你声明的数组,分配的内存),即使是主机访问也需要通过声明 sycl::host_accessor
,否则程序的行为是 UB;二是看起来比较简单且更接近 C 语言指针分配内存的 USM (Unified Shared Memory) 模型,这种情况下首先通过 sycl::malloc_shared
API 分配主机和设备都能访问的内存,因此用户需要保证主机和设备对数据的分开访问 —— 其实也很简单,就是等待作业完成后主机再访问这片内存就好了。
更多的像是 barrier 啊、fence 啊这些同步机制 SYCL 当然也是有提供的,但是这也要说那也要说博客就写不完了所以就算了。
OpenMP 也是一个挺常用的并行计算的东西,和 DPC++ 这种需要 icpx
支持的东西不同,几乎所有的常见/系统自带编译器都支持这个玩意;因为 Intel DevCloud 给我的服务器只有 CPU 和 FPGA 模拟器,而根本没有什么 GPU,我还思考了这个问题一段时间,这里给出解释——
#pragma
,以指示编译器在运行时并行执行这些代码。简而言之,OpenMP 只支持在 Host 上并行运行代码,只应对了并行编程中共享内存并行编程这一领域;而异构计算则是不只要在主机上跑,还要在主机之外的计算平台上跑 —— 就算是用 OpenGL 这些,在 GPU 上跑的代码都得用 Shader Language 写完了编译完了再给它送过去,而 SYCL 代码两边都是 C++!只是在设备上跑的部分会受到限制而已,确实是可以更为方便地编写高性能计算!
Vulkan 和 SYCL、OpenCL 一样,也是属于 Khronos Group 的标准;不过主要是用与图形渲染的,不过现在的图形 API 中都会有 Compute Shader 这个概念,所以就会让我这种图形学学徒产生一种万能感 —— 那我为什么不去用美丽的 Vulkan 呢?想了想如果有原因的话大概是这样:
综上所述,Vulkan 也可以用于异构计算,但是它的异构仅限于使用支持的 GPU;尽管 GPU 是异构计算中最为常见的设备,但是它代表不了异构计算的全部!所以我的评价是,用专门的库干专门的事,不要搞七搞八()虽然 SYCL 还是有不少过程和 Vulkan 蛮像的,但是人家毕竟还是高级抽象层!不能比!
然而,已经被 Vulkan 配环境气晕的我一点也不像再在电脑上配置一个新的环境;那这种情况呢就可以使用 Intel DevCloud 服务:这样吧,首先在浏览器的地址栏中输入 https://devcloud.intel.com/oneapi/get_started/,然后点击登陆,注册一个新的账号就可以获得 Intel DevCloud 的几个月的访问权了!
获得访问权限之后,就可以根据指示配置电脑上的 SSH 了;Windows 需要手动而 Linux/Mac 有脚本可以用;总之最后 Intel 会在 SSH 配置中增加它的登录节点、计算节点以及 VSCode 服务器;登陆节点用于提交任务到计算节点,并查看任务运行的状态,并不能运行很多东西,不然就会:
而要开启一个计算节点,则需要根据官网中所说先在登陆节点中使用 qsub sleep.sh
提交一个睡眠任务:
1 |
|
然后通过 qstat -n -1
命令就可以看到为我们分配的计算节点的名字:
图中选中的部分就是计算节点的 ID,可以看到我运行了两个睡眠任务(两个脚本只是名字不一样),一个的状态是 Q
说明还在排队,而另一个是 R
说明已经分配了计算节点并且正在运行。然后因为 Intel 已经配置好了我们的 SSH 客户端,所以我们可以直接通过 ssh <node-id>.aidevcloud
就可以连接到这个计算节点,保持这个连接打开的情况下,就可以通过 VSCode 的 Remote-SSH 插件连接到 devcloud-vscode
了:
用服务器开发那确实方便啊…… 可是这种先让服务器睡眠,然后再用终端骚扰计算节点的方法,怎么那么像当年那会 Github Actions 刚出来的时候一堆“聪明老哥”嫖 Github 服务器的样子呢?(不)
因为只能用 VSCode 远程开发,所以显然是没得 CLion 用了。习惯来说我会使用 CMake 管理项目,可是 VSCode 的 C++ 插件一直都挺奇怪的,似乎不是很和 CMake 联动…… 解决方案是让 CMake 构建的时候输出编译命令,这样就会被 VSCode 的 C++ 插件读取到正确的配置了。
1 | set(CMAKE_EXPORT_COMPILE_COMMANDS ON) |
此外,还需要强调使用 icpx
作为编译器,并且需要显式标记使用 sycl
库;因为编译器已经锁定,所以所有的编译选项只要直接加入编译命令中就好了。
1 | if(UNIX) |
接下来编译并运行示例项目 vector-add
,看起来成功运行了:
接下来就可以开始写作业了。
作业一共有三个选题。都是密集计算中的经典问题,很适合拿来入门异构加速计算。
完整实现参见 Github: ymd45921 / oneapi-homework
矩阵乘法是并行计算中的经典计算密集型问题,很适合并行加速;之前用 OpenMP 的时候就知道给三重循环中的一层或者多层套上并行,在这里只是换成了用 SYCL 来描述,道理都是一样的:
1 | q.submit([&](sycl::handler &h) { |
这里使用核函数并行地执行前两层循环,同时计算矩阵多个位置的值。但是访问的局部性并不好,因为计算矩阵的每个单元都需要访问整个矩阵的一整行和一整列,这里的数据跨度显然是很大的。
此外,虽然使用二维数组的方式直接声明矩阵有各种方便,但是因为栈空间是有限的,想要处理大数据还是应该动态地分配内存,所以实现了 RAII 矩阵类 my::mat
,内部使用动态分配的二维数组存储数据。而且 DevCloud 的服务器内存*这——么——*多,不用白不用不是?
对于并行矩阵乘法这个问题来说,分块优化是一个很经典的优化;虽然分块矩阵乘法和普通的暴力矩阵乘法的复杂度是一样的,但是分块矩阵的每次会将同行和同列的一整块的子矩阵相乘,较小的矩阵更容易全部放进缓存中,从而避免了缓存缺失,并具有了更好的局部性,从而更快的完成计算。
1 | q.submit([&](sycl::handler &h) { |
这里使用到了数据同步机制之 barrier:它要求当前工作组内所有的工作项都到达 barrier 所在的位置后才能继续执行下去;这里使用了两次 barrier —— 第一次使用它的原因是再利用 a_t
和 b_t
计算之前,需要保证每个工作项都正确加载所需要的局部数据;也就是说必须都完成了对 a_t
和 b_t
的写操作之后,才能读取他们的值用来计算 acc
;同样,第二次使用它的原因是需要保证所有的工作项都完成了这一轮对 acc
的贡献才能进行下一轮贡献的计算,保证写入全局矩阵 c
之前已经完成了所有必要的计算;总之,就是避免出现数据竞争。
可以说是最经典的学以致用环节了。一直在教科书中都很抽象的局部性在这里得到了鲜明的体现。在这之上还有 Cannon 算法,不过懒得学了,之后再说()
可以看出并行的矩阵乘法比暴力不知道高到哪里去了,而分块优化后时间还能再进一步的优化 50% 左右的时间,只能说快中快。
关于 Device 时间和 Kernel 时间:前者指的是主机开始向队列提交任务到任务完成的时间,是从主机的角度统计的时间,除了计算消耗的时间,还包括了调用 SYCL 抽象层和传输数据等的开销;后者则指的是在创建队列的时候开启了 sycl::property::queue::enable_profiling
之后,命令的执行时间,可以看作真实的计算时间。
归并排序是一个很经典的递归问题,但是在并行执行下显然递归有点抽象;第一想到的就是把递归改成从下到上的合并;然后等待每一层完成合并了再去合并下一层:
1 | for (auto stride = 2; stride <= size; stride *= 2) { |
所以新的任务必须得等待上一轮并行运行结束后才能发起…… 那这段时间 CPU 就只能空等了啊!略蠢。
思路不是特别清晰,但是有一个大致的方向是尽量减少在等待的线程 —— 因为归并的问题数量逐渐减少,问题规模逐渐增大,所以到最后就会只有几个线程在工作,而其他的线程都在旁观。在网上看到了很暴力的两头归并,只能说感觉不是很有意义== 有没有什么更加行之有效的,一看就很爽的方法?
还有就是这里的代码是每层完成了合并后才会开启下一层的合并,这显然可以优化成前面部分合并足够上一层了就触发上层合并。但是一方面感觉带来的优化空间不多,另一方面也完全不知道该怎么写,干脆摆烂了==
一般而言,在并行计算中的归并排序更多的则是 Odd-Even-Merge-Sort;在 NVIDIA 的 CUDA 实例代码中似乎是有的,还被 Intel 拿来示范使用转 DPC++ 工具。可惜我是个土地瓜,之前也没听说过这个排序,也没有学过别的异构计算,一时半会不是很能看得懂(瘫)
虽然没有优化,但是这样的速度也已经薄纱手写的归并排序和 STL 的 std::stable_sort
了,只能说摆了;等着有懂哥来教我怎么办了==
图像卷积,或者说卷积运算,实际上就是用一个一般是正方形奇数边长的小矩阵作为”卷积核“,对于图像中的每个像素,滑动卷积核并将其每个元素与图像的局部区域对应位置的值进行乘法运算,然后将结果相加,从而生成新的图像。这样可以提取出图像中的特征,并根据需求增强/减弱它。作为数字图像处理的经典问题,当然是可以通过并行来加速的。
最暴力的实现和暴力的矩阵乘法差不多,也就是加速外两层循环,同时让多个计算单元计算多个像素位置的卷积结果。核函数如下:
1 | queue.submit([&](sycl::handler& cgh) { |
中途曾经出现过很抽象的结果,一度让我怀疑我对图像卷积的理解是不是出现了问题,然后才发现我傻逼了:我们一般描述图像是 宽×高
,然而当在 C++ 中使用二维数组表示图像时,其实代表图像有 高
行和 宽
列,也就是坐标什么的得反过来…… 只能说确实图像处理代码写少了()
一种很显然的思路是像矩阵乘法那样,尽量让一个组内的工作项处理一块连续的图像 —— 对图像分块就好了。将图像划分成每个小方块,然后每个工作组处理其中的一小块,具有更好的局部性;但是我懒了…… 嗯。至于别的思路只能说毫无想法,有没有懂哥教教==
锐化似乎就是这样……?从性能上看更多的成本在数据传输&抽象层上,实际计算很迅速。
那么这次的作业就暂时告一段落了。虽然很难说我会用 OneAPI 了,但是至少透过这样的一个标准浅浅窥探了一眼异构计算的领域到底是什么样的,想必对之后写 Compute Shader 或者是学 CUDA 一定大有裨益吧(笑)
为什么后进队列的任务会被先排到计算节点?(
虽然说 Vulkan 是低级 API,而 SYCL 是高级的抽象层,但是感觉现在写的 DPC++ 基本上也是一直在描述模型啊,优化什么的都是编译器给干的;现在尚且还能写一些时空局部性的优化,除了这之外也算是毫无想法,至于别的什么高级的用指令优化呢,那是和用户没有一点关系…… 而且又是 LLVM 啊== 让我想到了之前和友人吹牛
唉,于是我是不是也该考虑面对 LLVM 呢?要学的东西太多了!
本文中所有的漫画都来自于: https://hacks.mozilla.org/2018/03/es-modules-a-cartoon-deep-dive/
不论是浏览器还是 Node,ESModule 规定都是按照如下步骤进行:
import
语句构建依赖关系图,加载所有需要的代码模块实例是一个引擎内部的数据结构,也是 ESModule 中的概念;将依赖关系图中的模块符号彻底转化为实例的过程就是加载模块。
在 ESModule 的语境下,每个代码文件都应该是一个模块;但是实际上使用的过程中需要将它们转化为内部的数据结构 —— 模块记录:
模块记录中至少包含了代码 AST,以及它导入的模块入口和导出的符号。
模块实例包含了两个部分: 代码 + 状态;这里就可以用组成原理的类比:它们都储存在内存中,但是代码是可执行的,而状态则包含了待处理的数据。加载模块的最终目的就是将文件的模块转化为内存中模块实例,以供引擎使用。
模块映射通过模块标识符区分模块,追踪一个模块的加载状态,并缓存模块记录和模块实例。
模块映射的存在保证了同一标识符所指向的模块只会被加载/执行一次;之后所有对该模块的重复加载都共享了同一个模块实例。
ESModule 的模块加载分为三个阶段 —— 构造、实例化、求值
正是因为 ESModule 将加载模块分为了这样的三个阶段,因此使得模块的加载具有了异步的可能 —— 这种异步性是不严格区分这三个阶段的 CommonJS 和按顺序加载的浏览器所不具备的。
但是 ESModule 并没有严格地规定死模块加载的进行方式;实际上,ESModule 的标准只约束了三个阶段中的解析、实例化、求值的环节;而将如何获取模块文件的问题留给了引擎。一般地,引擎通过装载器决定从哪里如何获得模块文件。
而且,虽然模块的解析、实例化和求值的过程都是被 ESModule 规范控制的,但是实际上怎么进行还是引擎说的算 —— 引擎可以通过调用 ESModule 的 API ParseModule
、Module.Instantiate
和 Module.Evaluate
来控制这些过程实际发生的时间。
接下来分别介绍加载模块的三个阶段:
对于每个模块而言,构造阶段又分为了三个阶段:
和任何模块系统一样,要加载一个模块,首先需要知道它的入口;而在浏览器中,使一个 JS 文件成为 ESModule 入口点的方法是将它声明为 type="module"
;
从入口模块开始,JS 文件中会包含 ES6 规范下的导入语句 —— 这些导入语句中字符串的部分就是模块标识符,它告诉加载器如何找到带引入的模块;但是加载器如何理解这个模块标识符则取决于引擎:Node 会根据这个字符串确定是相对地址、绝对地址还是模块名,再进一步查找入口文件;而浏览器只会单纯地将字符串考虑为 URL。
引擎从入口开始边加载模块便构建依赖关系图。显然,整个图上的文件不是同时下载的 —— 因为构建依赖图必须是层层推进的,只有加载完了一个文件才能分析它的依赖,从而知道下一层的文件应该如何加载。
显然,主线程不应该停下来等待所有文件加载。而 ESModule 将加载模块分为了多个阶段,而不是像 CommonJS 那样一把梭则为这里的并行提供了依据:实例化和求值的阶段是同步的,需要已经有了整个模块依赖图,因此代码的运行必须在所有模块加载完成之后;但是加载模块环节的内部则完全可以是并行的。
正因为 ESModule 的模块加载和执行是完全分开的过程,所以在 CommonJS 里常用的动态导入的语句在 ESModule 下就不成立了:
这很好理解;ESModule 在加载模块的时候不知道任何运行期的值,自然无法通过一般的导入语句实现动态加载,但这不意味着动态导入没有意义。为了满足这种场景使用动态加载的需求,除了一般的 import
语句之外,ESModule 还引入了 import()
语法。但是这种导入又是怎么融合进我们刚才所说的加载-执行分开的体系呢?
答案是,任何 import()
加载的模块会被作为一张新的模块依赖关系图的入口;这张新的图的处理是单独进行的,有独立的三阶段。
这样,动态导入只是在入口模块所在的图完成构建并且执行的过程中,触发的新的加载模块任务罢了;新图的构造器当然可以知道旧图运行期的值。
完成了整张模块依赖关系图的构建之后,就该进行构造阶段最后的步骤 —— 解析;在解析阶段,引擎将之前已经获得但仍然未被解析的模块文件解析为模块记录并加入模块映射中。
在解析模块文件时,引擎的行为和解析一般的 JS 文件相比有以下不同:
"use strict"
)进行解析await
在顶层代码(函数之外的代码)中被保留this
的值是 undefined
这都是 ESModule 规范所要求的;当整张依赖关系图上的所有的模块都已经被解析为模块记录并且加入模块映射后,就代表着构造阶段的结束。
目前,引擎中的模块还是以模块记录的形式存在,距离模块实例还是相去甚远。而实例化阶段就是根据模块记录的信息为模块分配内存,并且创建导入导出符号之间的关联。
对于每一个模块记录而言,JS 引擎会创建它的“模块环境记录” —— 它包含了为所有导出变量和函数创建的内存,其中导出的函数这个阶段已经被初始化了,但是导出的值没有求值;接着将这些内存和导出的符号相关联,并且关联自身导入的符号和对应的内存。
对于整个模块依赖关系图,JS 引擎会执行深度优先后序遍历 —— 也就是它每次都尝试深入到依赖图的底部,找到一个没有任何依赖项的模块,然后再为它设置模块环境记录和导出内存。当完成了这些模块的实例化后,就会返回上一级模块(对下层模块有依赖的模块),将这些模块的导入和下层模块的导出(的内存块)相连接。
先设置导出在绑定导入可以保证每个导入都可以正确的连接到导出,而且这样的动态绑定保证了在求值之前就可以完成当前依赖关系图上所有模块导入导出的链接,这被称为“动态绑定”;这种处理导入导出的方法和 CommonJS 有着本质的不同:
CommonJS 只会会按照顺序执行模块,在遇到导入模块时将控制流转移到新的模块中;模块的执行产生的变量只会留在当前模块的内存中,导出的只是副本,模块内部后续的更新不能被依赖它的模块观察到;但是 ESModule 则是在执行前就确保了导入和导出的符号指向同一块内存,当模块内发生变动时,可以被所有依赖它的模块观察到。
已经为模块实例的状态分配了内存和链接,接下来要做的事情就是为这些内存赋值;JS 引擎通过执行顶层代码(除了函数之外的代码)来实现这一点。
但是这些顶层代码未必是无副作用的;正是由于这些可能的副作用,你只希望这些代码执行一次,毕竟副作用的存在可能使得多次执行这些代码会产生不同的结果;而模块依赖关系图/模块映射的存在就可以确保这一点。
和实例化阶段的链接过程一样,模块代码的执行也是按照深度优先后序遍历的顺序完成的。由于再执行之前已经建立了模块之间的动态绑定,所以即使出现了循环依赖也不应该出现什么问题;当然优秀的程序设计中不应该出现循环依赖就是。
]]>Mac 的 App Store 跟玩儿一样的,,,不过再怎么说毕竟是电脑,这样倒也无可厚非;所以很多日用的软件都得侧载;我从下面的这些地方侧载了一些常用软件:
Mac 大多数的安装包都是 .dmg
的,这种 Cask 型安装包打开就是一个应用图标和应用文件夹图标,只需要把应用图标拖进应用文件夹就行了。
安装完 Jetbrains Toolbox 然后安装 CLion 之后就会自动安装 XCode 编译链,具体来说就是 clang
相关的工具。此外,非常疑惑的是 Toolbox 创建在启动台的“快捷方式”是 Intel 版本的,但是通过终端命令行和 Toolbox 启动的版本都是正确的;所以眼不见为净,可以在 ~/Applications
中找到并删除 Toolbox 在启动台创建的“快捷方式”。
VS Code 并不会自动安装到 PATH,需要安装完成后运行 VS code 并打开命令面板( ⇧⌘P ),然后输入 shell command 找到:Install ‘code' command in PATH
并执行。
Homebrew 是 Mac 上最常用的包管理工具,基本上不能没有。Homebrew 官网提供了安装命令:
1 | /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" |
要从 Github 上下载,所以需要代理终端;该命令执行完毕后,控制台上会输出用于将 Homebrew 增加到 PATH 的命令,需要手动执行。
新版的 Homebrew 已经集成了 brew-cask
—— 这是一个用来安装上述 .dmg
格式安装包的命令行工具;所以不再需要专门安装。
清华源: https://mirror.tuna.tsinghua.edu.cn/help/homebrew/
执行下面的命令将清华源的地址放进环境变量中:
1 | test -r ~/.zprofile && echo 'export HOMEBREW_API_DOMAIN="https://mirrors.tuna.tsinghua.edu.cn/homebrew-bottles/api"' >> ~/.zprofile # zsh |
其实原来的源也不是很慢,清华源在拉取仓库之后也挺慢的,,,应该是我的问题,但是为什么?所以有手段科学上网的话不换也行,反正终端代理之后飞快。
可以借助第三方 GUI 工具,也可以运行命令:
1 | brew cleanup |
这条命令清理了旧的和不需要的软件包、缓存和一些生成的文件。但是这条指令默认只会清理 120 天以前的下载缓存。
观察 HomeBrew 缓存目录就会发现除了数个索引文件,就是软件包和软链接;它们大概是这样组织的:
Cask
子目录则存放了指向所有 HomeBrew Cask 软件包的软链接downloads
目录中.incomplete
作为扩展名基于这些规则,我们也可以编写脚本手动清理这些文件,从而更为及时地释放硬盘空间;但是因为我很懒,所以暂时先摆了。
Mac 默认的终端就是 zsh,所以我们不再需要额外地下载 zsh;就像之前说的那样安装 oh-my-zsh 就行了;首先运行下面的命令安装本体:
1 | export https_proxy=http://127.0.0.1:7890 http_proxy=http://127.0.0.1:7890 all_proxy=socks5://127.0.0.1:7890 # 设置终端代理 |
Mac 没有自带 wget
,所以用 curl
安装。
powerlevel10k 是一个很牛逼的主题,有多牛逼这里就不再赘述了;简单地说,因为它有个向导可以捏捏捏,所以它一个主题可以抵得上很多个主题;它通过以下命令下载:
1 | git clone https://github.com/romkatv/powerlevel10k.git $ZSH_CUSTOM/themes/powerlevel10k |
然后修改 vim ~/.zshrc
中的相关行为 ZSH_THEME="powerlevel10k/powerlevel10k"
;然后 source ~/.zshrc
,就会进入它的设置向导 —— 向导会自动帮你安装 Meslo Nerd Font
字体,也可以通过 Homebrew 安装其它的 Nerd 字体:
1 | brew tap homebrew/cask-fonts |
这个设置向导之后也可以通过 p10k configure
再次进行。
还是在 ~/.zshrc
文件内,可以找到插件的一行;一般来说要使用这三个插件:
1 | plugins=(git zsh-autosuggestions zsh-syntax-highlighting) |
可惜现在后两个似乎不自带了,还得手动安装;可以通过 Homebrew 来安装这些插件,也可以克隆仓库来安装:
1 | git clone https://github.com/zsh-users/zsh-autosuggestions ~/.oh-my-zsh/custom/plugins/zsh-autosuggestions |
安装之后 source ~/.zshrc
一下就起用了这些插件。
可以在官网上下载:https://iterm2.com/downloads.html
下载的是一个 zip 压缩包,解压后可以得到 iTerm.app
目录,这个在 Mac 下看就是应用;将它拖到应用程序文件夹就可以了。
在屏幕上方的 iTerm2 菜单中:
然后可以在这里打开 Settings… 窗口,也就是旧版本中的 Preference 窗口;这里右上角可以搜索设置项,所以有的就不列完整的位置了,搜索就行。
默认的窗口标题是 用户名@拼音谁谁谁的Macbook
实在是有点抽象;所以可以在这里覆盖这个默认值:
这里有个 Custom window title;当然可以写死,但是这个是支持插值的,所以也不是不能让它稍微能动一点;它使用 iTerm2 自定义的插值字符串语法,比较难用;iTerm2 根据上下文分类定义了一些预设变量,可以在这里查阅,而且这些变量有的还要安装 iTerm2 集成才能用 —— 上文已经说了要装了。
遗憾的是,我的这个 iTerm2 已经删去了 Terminal may set tab/window title
配置项;所以不能用 Shell 的钩子比如 precmd
和特殊转义字符来更自由地修改窗口标题了。
虽然三年前看到好兄弟用 iTerm2 羡慕的要死,但是这几年 Windows Terminal 发展的还挺好,反倒是回头看 iTerm2 过了几年还是这副摸样,只能说感觉弗如 wt
,它什么时候登录 Mac()
顺便上面那张图里的模糊效果,不知道是不是用 Mac 原生的方法实现的 —— 不是说原生有多好,至少原生的话就可以和系统融为一体,不会因为模糊半径的问题而太突兀;因为不确定这一点所以我就没开,当然开了肯定还是挺好看的……吧。
太久没写代码了,也不知道还有什么要装的了,,,总之这次就先这样了。
]]>总的来说,我的修理博客大概包含了三个方面的工作:
基本上也都完成了;总结一下做的工作和做的一些事情,以供参考。
最开始想着用腾讯云的,毕竟又有看起来比较通俗易懂也令人安心的成本控制,还是大厂;但是最后还是又拍云了,原因有二 ——
rclone
进行备份,但是配置 rclone
需要学习,很麻烦;又拍云也给了一个备份工具 upx
勉强能用所以就是写了个脚本,匹配博客里所有图片的 URL,然后把所有是图片 URL 的下载下来批量上传又拍云了。遇到了一个问题,就是新版 sm.ms
的图片似乎在浏览器直接访问(可能是没有 Referer?)的情况下会跳转到图片详情页面,导致用 axios
会爬一个 HTML 下来,很愚蠢 —— 但是当时我也摆了,所以就手动保存剩下的图片了,只能说旗鼓相当== 毕竟能不要写代码就不要写代码嘛()
此外,又拍云开境外加速好像有一点贵,所以没有开 —— 这样的问题就是图片只能在大陆访问了;不过本来人就在境内,博客本身放在境外也只是为了省钱而已,不寒碜==
图片只存一处那肯定不太安心;最好就是一边备份到 OneDrive 这种成本比较低,又比较安全,甚至有的时候还可以搞七搞八的地方;我就是使用又拍云提供的 upx
,虽然它没有提供真正意义上的同步功能,但是基本的批量上传和下载还是可以的 —— 于是就在终端里设置了一个把又拍云全桶下载到某个 OneDrive 同步的文件夹里的 alias
,设置定期执行定期手动执行()
既然命令行只备份,那么上传就得通过别的方法 —— 不过反正有 PicGo 这种方便的东西,倒是也不用自己搭图床,又麻烦又不怎么安全==
之前用的是 Valine,但是现在新版 Volantis 直接不支持了,也行()Twikoo 之前也用过,不错,但是一方面云开发收费了,另一方面也看到了有人的 Twikoo 被刷,而且甚至会导致 Vercel 封号…… 还挺搞人的,,于是直接摆烂选择 Github 系,完全放弃国内的可访问性,也不管游客了==
最后选择的是基于最新最热的 Github Discussion 的 Giscus;它提供了一个 Bootstrap 网站,可以根据他的指引一步步来,也可以私有部署不过我觉得没啥必要就是,它的公共部署似乎也是在 Vercel 上,访问的多所以应该总是在前台,不会有冷启动的损失。
Giscus 似乎是个云函数;如果有可能,说不定也可以自己部署来加速;不过那就是之后的故事了。
现在 Hexo 已经迈进了 6.x 版本,在之前还算比较罕见的通过包引入主题现在已经是常规操作;所以最好的方法就是把之前整的 hexo-theme-ymd45921
改改,然后发个包。
最新的 Volantis 是 6.x 版本了,但是删除了我个人感觉不能没有的 Pjax —— 仅保留了一个不再更新的包含 Pjax 功能的分支;可能是因为现在的新活比较多,不支持 Pjax 的插件越来越多了吧,毕竟我好像还看到什么博客内置 GPT 的插件呢(笑)只能说 ChatGPT API 开放后,确实已经成为了目前最大的前端训练场,还挺…… 符合一些认知的()
所以修改主题也只能基于这个还有 Pjax 的分支;浅浅看了一下新加的东西,确实也没啥感兴趣的功能,硬要说之前的主题也是完全能用的()所以也没啥问题。
先在主题仓库下运行 yarn link
以暂时将它注册为全局可用的依赖;然后回到博客仓库目录下,运行 yarn link hexo-theme-ymd45921
,就将这个依赖暂时关联到博客项目,就可以边开发边看效果了;之前那样每次测试都要发一个版本实在是有点谔谔……
为了适应旧版本,fetch
新版本之后开发之前除了要处理配置文件的冲突之外,还要引入一些已经被新版本废弃的资源,比如 Font Awesome 5 Pro;虽然现在有 6 了,盗版也满天飞啊,但是反正博客用 5 也绰绰有余,还省的自己布置静态资源。
本身发布很简单,就是 yarn publish
完事了;但是由于 ymd45921
这个用户名实在是过于钓鱼,怎么看怎么像机器操作随机生成的,所以用这个用户名命名的主题 hexo-theme-ymd45921
被 npm 识别成垃圾了== 本来想着发布到用户的命名空间下吧,结果 hexo
还找不到 @
开头的包…… 明明是渲染器就能找到的…… 所以只好改名字了。
因为是 Volantis,然后又是自用自改,在种子站上又能经常看到同义词“自炊”,所以干脆就叫“自炊 Volantis”了()反正也没打算给很多人用,名字什么的无所谓啦(
我认为对博客的修改包括两大类:一种是会影响功能的,一种只是修改样式;虽然说后者是推荐在自己的博客仓库里搞一个样式表然后外挂嘛,但是毕竟在写主题,有些东西明显比原来更好或者是原来存在一些问题的,直接在主题改改就完事了。
我一直觉得 Volantis 的友链格式是比较奇怪的 —— 只有一个 title
,但是网站名和用户名还是有着较大差距,所以就自己动手加上了对 author
的展示;因为我没想好 Volantis 的 traditional
友链视图应该改成怎么样,所以只改了 simple
视图。
友链的视图出现在 friends.ejs
中:
1 | <% if (item.url && (item.title || item.author)) { %> |
为了保留对 Volantis 的兼容,新加的 author
并不是必须的属性;只有在判断存在 author
的时候才使用新的模板其实也就是在原来的标题下显示一行小字;再加上对应的样式就行了。
曾经,我还是使用 pandoc
作为 Hexo 的渲染器的小混子 —— 其实 pandoc
还挺好的,它本身渲染 Markdown 就支持了数学公式(而且支持的很好),根本不用像其他的渲染器一样还得装数学插件然后搞七搞八…… 但是这样就没法用 Vercel 搞 CI/CD 了,因为后者不能安装 pandoc
的二进制,而这是 Hexo 的那个渲染器所必需的。换当然也有这之外的动力 —— 毕竟新电脑上也没有 pandoc
,虽然装起来也不麻烦就是了……
那么用其他的渲染器,就会面临数学公式渲染出错的问题;这也算是 MathJax 的老毛病了,因为它是个文件引入嘛,所以你文章里的公式就会被原模原样地复制到最终的 HTML 文件里,当用户浏览器加载了这个网页的时候才会进行渲染;但是这样就会面临一个问题 —— Markdown 渲染本身也是有一些转义符的,这些公式在复制的过程中其实或多或少都受到了影响 ——
毕竟作为一个拓展标准,实际上并没有人规定 Markdown 的公式块里的公式的 \
是否应该再被转义一次(至少我不知道);虽然看起来并不应该转义,毕竟嘛,每个命令都要加两个 \
实在是太愚蠢了对吧()但是因为没有规定,所以怎么实现都算是实现了…… 嗯==
当时在选择 pandoc
作为渲染器之前就试过使用 KaTeX,虽然它确实被广泛认为比 MathJax 快,而且明确地支持服务端渲染,所以似乎也没有转义的问题,但是它支持的不够完善 —— 比如不支持把 \and
和 \or
翻译成逻辑运算符,然后没有 align
但是却有完全一致的 aligned
;
精心挑选了看起来比较好懂的渲染器 markdown-it
,它也有比较浅显易懂的 KaTeX 插件;简单阅读后发现是识别 $...$
和 $$...$$
内的文字后,调用 KaTeX 的函数得到渲染结果 —— HTML 格式或者是 MathML 格式的,不是 svg 这种高级东西。最简单的思路就是在渲染器处理完文法得到待渲染公式的时候,先手动进行一些替换再交给 KaTeX —— 比如 align
:
1 | token.content = token.content |
已经解决了最令人头疼的 align
问题了;又看了看 KaTeX 的官方文档,还可以手动增加命令宏 —— 这样就可以解决剩下的问题了,善哉善哉;把修改后的渲染器打个包发布,就可以用起来了;不过话说回来 KaTeX 不支持的符号也实在是太多了…… 还听说 MathJax 3 的渲染性能大有改进,不会是五零年入国军把,,
总的来说,就是先卸载已有的渲染器,换成七海特制渲染器:
1 | yarn add @kohaku/hexo-renderer-markdown-it |
这里 Hexo 明明就可以跨过 @
找到渲染器,为什么主题就不能呢()
KaTeX 有两种渲染方式 —— 渲染为 HTML 和 MathML;前者肯定是广泛支持,但是需要设置样式辅助;需要引入它的样式表:
1 | <link rel="stylesheet" href="https://unpkg.com/katex@0.16.7/dist/katex.min.css"> |
后者则是通过 <math>
块,而这是一个新的特性,支持情况如下:
虽然我还是更喜欢 MathML 的样式,而且实际上 MathML 渲染更快,但是为了可恶的兼容性……
因为块状的公式可能会超长,为了在这种时候能够左右滚动查看公式,可能需要为公式块增加滚动样式;我觉得 Volantis 应该本身就做了这个,不需要手动加,但是以防万一提一嘴;KaTeX 会把渲染结果丢进 .katex-block
中,所以你需要为它设置样式 overflow-x: auto
。
新版的 Volantis 不再使用丑陋的 document.execCommand()
或者是麻烦的外部库复制到剪贴板,而是使用浏览器内建的 navigator.clipboard
对象 —— 但由于浏览器新的安全策略,它只在 HTTPS 这种安全的环境下才能工作;~~没有搞 HTTPS 的人有难了!~~但是因为也确实还遇到了 SSL 证书的相关问题,这种时候用不了还挺麻烦的,所以想着要改成失败的时候 Fallback 丑办法:
1 | // 使用 document.execCommand('copy') 的蠢办法 |
这样,在不安全的环境下也可以复制文本了,善 —— 虽然对我来说,不安全环境的场合也只有本地就是了(通过绑定了局域网 IP 的域名访问开发服务器)
虽然不管是示例博客还是使用了这个主题的其他博客,甚至还是我的博客怀旧服,右键音乐播放器总归是会正常显示的;但是在更新了新版本主题代码之后,它却时常缺席,怎么回事捏?
可以大概确定,每次页面加载的时候,右键菜单会确认一次 APlayer 是否装载,如果没有装载的话就不显示;这样就有一个显而易见的解释 —— 右键菜单确认的时候如果 APlayer 确实没有加载好,那么就会导致右键音乐播放器一直不显示。
跳转到 aplayer.js
:整个文件就声明了一个 RightMenuAplayer
对象,然后将它冻结,并且最后调用 requestAnimationFrame
方法注册更新状态的函数 checkAPlayer
—— 就是这个函数更新了 APlayer 的装载状态 —— 但是却只注册了一次,然而:
备注: 若你想在浏览器下次重绘之前继续更新下一帧动画,那么回调函数自身必须再次调用
requestAnimationFrame()
。requestAnimationFrame()
是一次性的。
只更新一次那确实就不太对了,但是真要让这玩意每秒执行六十次也挺脑残的== 最后的解决方案是在右键菜单的检查函数里增加了一次调用 —— 在检测到 APlayer 装载之前:
1 | if (volantis.GLOBAL_CONFIG.plugins.aplayer?.enable) { |
这样确实就正常了 —— 但是看了一下 Volantis 的 Commit Message,这个文件在那之后也并没有什么改动…… 之前的版本都是这么写的啊不都没事,为啥会出这个问题呢()
感觉现在已经几乎习惯了 Stylus 的写法了;而且因为 Hexo 实际上是一个静态服务器,但是加上了很多的 Loader 插件,对不同的文件经过中间件(渲染器)转化后再发给浏览器/保存为文件;所以实际上博客仓库里的外挂的样式表也可以是 .styl
格式的。整挺好。
一种很直接的方法就是在导航栏里塞进两个图标,但是用 CSS 控制它们的显示;为了保留兼容性,会先检测配置文件是不是字符串;如果是就保留 Volantis 的行为,不是就按照上述方法进行。
首先打开导航栏所在的 header.ejs
并作出如下的修改:
1 | <% if (logo.img) { %> |
并在 CSS 中设置要隐藏的 Logo 的样式为 display:none
,将要显示的 Logo 样式设置为 display:block
即可。
在 Volantis 还叫 Material-X 的时期就有的 Feature,但是改名后就被删掉了;只需要从之前的老代码里抄过来就可以了:
1 | .l_header.auto.show |
狠狠地文艺复兴!
Volantis 的右键播放器的音量条两侧的两个音量图标总是一个颜色,这样当主题色是深色的时候看的很不清楚;理想是随着音量逐渐调大,左右侧的两个喇叭图标由深色变成浅色;变色的操作应该是由 JS 控制的,并且只会在音量改变时发生,所以只需要找到修改音量的回调并修改就行:
1 | fn.onUpdateAPlayerVolume = () => { |
我们设定:当填充到 15% 时左侧图标变色,填充到 90% 时右侧图标变色;并且指定变色后的图标具有 filled
类;然后再修改 CSS,为 i.filled
增加新的颜色。搞定!
现在 Volantis 用的已经不是 jquery-parallax
了,而是自己写了一个;本来也没啥,但是它切换图片的实现十分迫真 —— JS 分成一些步骤,每次透明度加一点 —— 这本来也没什么,但是它的步骤实在是太少了,所以看起来就一卡一卡很抽象()
加步数是不可能的,这辈子都不可能的,看着都丑;而要想要流畅的动画,一般都会想到 CSS 的 transition
属性;所以一种可能的解决方法是把步数改成一步,再增加一个 transition-duration
并让它等于配置文件中设置的时间的透明度渐变就行。
1 | if (opac !== 1) |
这个本来会执行步数次数的函数现在只会执行两次:一次修改透明度为 1
,一次清除旧的图片。
1 | img.parallax-slider |
利用 hexo-renderer-stylus
提供的 Helper 读取配置文件中的时间,并直接用于样式中。
但是这样也有一个小问题:不同浏览器对 CSS 动画的处理也不仅相同,比如 Firefox 似乎就会在窗口在后台是不播放这个动画,但是比起它流畅起来的样子,这些都是小问题==
在维修博客的过程中遇到的一些杂七杂八。
又拍云自动申请图床的 SSL 证书却总是失败,不知道什么毛病,,,发了工单说是域名有 CAA 记录但是显然我上 DNSPod 完全都查不到,真是令人费解。本来还以为是又拍云草台班子,但是当我上腾讯云申请证书的时候却同样不行,也说是 CAA,怎么回事呢?
CAA(Certification Authority Authorization)记录是一种 DNS 记录类型,它允许域名所有者指定哪些证书颁发机构(CA)有权颁发 SSL/TLS 证书。它的作用是提高 SSL/TLS 证书的安全性和可信度,防止不良的证书颁发机构颁发伪造的证书,从而保护网站的安全。
通过 CAA 记录,域名所有者可以限制哪些 CA 可以颁发 SSL/TLS 证书,如果不在指定的 CA 列表中,那么这个 CA 就无法颁发证书。这样可以降低 SSL/TLS 证书被恶意颁发的风险,提高证书的可信度。
要解释原因,首先要说明 CAA 记录的查询规则;当我们要为域名 xx.example.com
申请证书时,我们申请的 CA 会:
example.com
的 CAA 记录如果最后还是没有查询到 CAA 记录,就说明没有限制,CA 可以颁发证书;否则,则只允许查询到的 CA 办法证书,CA 会比对自己的信息然后好自为之 —— 结果就是颁发失败。
而之前我的博客是 CNAME
指向 Github 的,而听说 Github 遵循了最新最热的建议,设置了 CAA 记录;根据上面的查询规则,当我为图床申请证书的时候,会在第四步查询到 Github 设置的 CAA 记录:显然,Github 是不会允许免费证书 CA 为其颁发证书的。
于是就有两种解决方法:
前者十分方便有效,一关秒好 —— 也不会再开了,现在博客搬到 Vercel 上了;后者需要查询我们要申请的免费 CA 的记录值,一般也就是亚洲诚信或者是 Let’s Encrypt,像是腾讯云这种云服务提供商一般会提供。
顺便一提,Vercel 自动生成的 SSL 证书是 Let’s Encrypt 的;但 Let’s Encrypt 单域名下的证书的申请是有上线的,每周五十个;虽然几乎不会用到上限,但是由于并不知道 Vercel 内部是怎么实现的,有的时候它生成证书也会失败 —— 不过一般等等就好了虽然我等了二十几天。
虽然说 Hexo 文章本身就是 Markdown 文件,随便用 Markdown 编辑器创建文件就可以了;但是在 Hexo 中有文章模板,使用命令行创建文章会自动地填写一些 Front-matter;但是命令行只能创建文章而不能打开已有的文章,在这之后在 _posts
文件夹里找文章还是挺抽象的== 如果有一个命令在没有一个文件的时候创建文件,有文件的时候自动打开就好了——
观察到每次新建文件后会控制台输出新建的 Markdown 文件的路径,所以写了下面的脚本:它会再调用 hexo new
之前检查文件是否存在,并在存在的时候直接使用 Markdown 编辑器打开。
1 | const process = require('process'); |
将该脚本保存至一个文件,然后再在 package.json
里的 scripts
栏目加入一项以方便地使用它:
1 | "open": "node ./helpers/new-or-open.js", |
这样就可以通过 yarn open
来调用该脚本了;紧随其后的所有参数都会被传递给该脚本。
博客修好了就该写文章了!真的该动手了!
这篇文章甚至都是在博客修好前就列好小标题了,却一直拖到星奏的生日才想起来补好……
可能还是存在小问题,比如手机版 Firefox 限定的圆角矩形渲染错误(这 jb 怎么修),又比如在各种手机上 Pjax 存在延迟(从 click
事件到 pjax:send
事件之间存在随着网页打开时间而增加的延迟,感觉无从下手),但是不管了……
但是在日常开发特别是小组作业中,大家的提交消息千奇百怪也十分正常;你觉得你的提交消息已经够抽象了,但是总能找到比你更抽象的;中英文混合使用、fix bug这种说了和没说一样的笼统的消息也层出不穷,这就导致后续代码维护成本特别大,比如这次维修博客观看之前的提交历史,只能说我都不知道我修了个啥== 乱写 Commit 一时爽,后续维护火葬场。
其实也不能说是一时爽:很多时候也不能说是我们有意要瞎写提交消息的,有的时候这次 Commit 确实做的事情小到不拿放大镜看不到,为了这点东西想一段日后看起来不会一头问号的消息其实还挺尴尬的;这种时候就需要完善的规则来背书,才能避免这种前后都尴尬的情况。
其实网上的规范还挺多,也有约定式提交这种看起来就很高大上的东西;但是实际上大多数人说到规范的第一反应还得是 Angular 规范;这里简单的讲讲
总的来说,一条消息应该由以下的部分组成
1 | <type>(<scope>): <subject> |
用于说明本次提交的类别,可以在以下关键字中选择:
类别 | 含义 | 说明 |
---|---|---|
feat/feature | 新的功能 | |
fix | 修复 bug,已经解决了描述的问题 | |
to | 修复 bug,问题还没解决 | 适用于多次提交;这个 bug 我修了好久了 |
docs | 修改了文档 | |
style | 调整了代码的格式,完全不影响代码的运行 | |
refactor | 对已有代码的重构 | 一个更改,即没有增加新功能,也没有修复 bug,那它就是重构 |
perf | 优化代码的表现,提升了性能/体验 | |
test | 增加测试 | |
chore | 构建过程/辅助工具的变动 | |
revert | 版本回滚 | |
merge | 代码合并 | |
sync | 同步其他分支 |
其实还是挺抽象的…… 别的之后慢慢补充了……
用于说明本次提交影响的范围;可以具体到功能模块,这就取决于不同的项目了。
对本次提交进行简短的描述,往往不超过 50 个字符 —— 你超过了的话,VS Code 都会提醒你。
虽然说很多文章里都说要用英文,但是对于本就英文不熟悉的国人来说实在是有点要求过高…… 个人感觉如果只是自己的项目,自己写的三脚猫英语可能本身就是代码审阅时候的很大的障碍,所以要不还是不要折磨自己,乖乖说母语吧()
另起一行之后的 Commit Message 就不会出现在各大托管平台的提交历史页面了;而很多时候五十个字符也只能非常 High-level 的概括一下你做了什么—— 虽然说在正经的多人合作项目里不太可能出现这种事情,但是毕竟自己也会搞七搞八对嘛== 这种情况下就需要更加详细的说明。当然,我的的意思就是另起一行开始大段说明。
感觉区别不是很大,可以参见它们的网站。约定式提交 1.0
除了方便代码审阅之外,还有一些角度可以理解这样的好:
npm
中依赖和版本号的小结。知识盲区实在是太多了!npm
中的依赖npm
中的依赖主要有 dependencies
、devDependencies
和 peerDependencies
。
dependencies
vs. devDependencies
顾名思义,前者是在生产环境和开发环境中都使用的包,而后者是仅在开发环境中使用的包。
比如 gulp
、webpack
就是典型的只在开发环境中使用的包,无需打包到产品中。
开发环境和生产环境,可以通过 NODE_ENV=production|development
来指定。
对于 npm
而言,安装两种不同的依赖需要指定的完整标志分别是 --save
和 --save-dev
。
peerDependencies
对于一般的 dependencies
的场合:如果我们有包 A 引用包 B 作为依赖,而我们在我们的项目中安装了包 A 作为依赖,那么实际上只有包 A 被安装到了我们的项目的 node_modules
中,包 B 实际上被安装到了包 A 安装目录下的 node_modules
中。这带来的结果是,虽然包 A 和包 B 都安装过了,我们可以使用 require
引用包 A,但是却不可以直接在我们的项目中引用包 B。
但是如果在包 A 中,包 B 是作为 peerDependencies
引入的,那么安装包 A 时,包 B 会被同样安装到我们项目的 node_modules
目录下;此时就可以直接在我们的项目中引用包 B。
综上所述,peerDepenedencies
的含义可以理解为对包管理器的要求:如果你安装了某个包,那么我建议你也安装我的 peerDependencies
。
npm2
在安装包时会强制安装包的 peerDependencies
,不需要再宿主环境中指定对于这些包的依赖;而 npm3
不再强制安装这些依赖,而是在安装结束后检查本次安装是否正确;如果发现安装不正确则打印 WARN。不正确包括未安装和版本不匹配两种情况。
而如果出现了这种不正确的问题,只能手动解决:比如手动将这些依赖增加到 package.json
或者修改它们的版本使得这些依赖可以符合要求,然后 npm install --force
…… 太蠢了!
1 | WARN using --force I sure hope you know what you are doing |
npm
版本号规则整体来说,npm
的版本号的格式是 <major>.<minor>.<patch>
的形式。
出现在 package.json
中的各种依赖包的版本实际上是匹配规则,又被称为 npm
语义化版本。一般来说有以下常用的匹配规则:
写法 | 含义 |
---|---|
version | 精确匹配某个特定版本 |
>version <version >=version <=version | 大于、小于、大于等于、小于等于某个特定版本;表示了一个范围 将两个规则连写也可以表示一个范围;如: >=version1 <version2 |
~version | 大致匹配某个版本。具体来说规则如下: 1)指定到 <patch> :形如 ~a.b.c ;指大于当前指定的版本,但是小于下一个次版本号的所有版本2)指定到 <minor> :形如 ~a.b ;指固定主版本号和次版本号,补丁版本号任意3)指定到 <major> :形如 ~a ;指固定主版本号,次版本号和补丁任意 |
^version | 兼容某个版本,其含义是版本号中最左的非 0 版本号的右侧版本可以任意; 如 ^a.b.c 实际上等价于 >=a.b.c 和 <a+1.0.0 同时成立;而 ^0.a.b 则等价于 >=0.a.b 和 <0.a+1.0 同时成立;如果缺省了某个低权重的版本号,那么缺失的位置可以任意(此时类似 ~version ) |
标识符 x | 标识符 x 的位置可以填入任何数字 |
标识符 * | 表示任意版本;等价于留空规则;严格的来说等价于 >=0.0.0 |
version1 - version2 | 匹配了 [version1, version2] 双闭区间的版本 |
`range1 |
由此可以看出 npm
语义化版本中有各种各样的模糊和范围,这为前端的工程化引入了一些问题:当某些包升级过程中没有遵循语义化版本,可能会导致每次打包生成代码都不同;所以我们需要特定项目依赖的包的版本号,为此各大包管理器都引入了 lockfile
的机制来锁定项目依赖的版本号。
开源的包一般都不包含 lockfile
,其原因可能是为了避免特定过多具体的包导致引用较多开源包重复打包某个包的不同版本使得工程体积膨胀,故只能信任其依赖的包遵循语义化版本的要求,某个小版本/大版本的功能不发生过大变化而可以兼容。
install
和 update
以 npm
为例,安装有 install
和 update
两种命令;它们之间的区别主要体现在两个方面:
install
会忽略模糊版本,而 update
会更新模糊版本至最新版。
对于未安装的包,两者都会直接安装
devDependencies
install
会安装/更新 devDependencies
,除非指定 --production
标志。
而 update
会忽略 devDependencies
,除非指定 --dev
标志。
这个积分既不能直接凑出来,也不能使用分部积分法消掉什么;一般,我们会使用升维的方法转化到极坐标来解决这个积分,就大概是下面这样:
这样,使用极坐标变换可以得到:
显然被积函数恒大于 0,故 ,综上所述可得:。
每次遇到这种积分都要这样搞一遍实在是有些麻烦,有没有更系统化的方法呢?经过查阅 Wolfram Alpha,得知这种形式的积分可以使用 Gamma 函数表示。
伽马函数也叫欧拉第二积分,是阶乘函数在实数与复数上扩展的一类函数,是阶乘函数在复数域上的延拓。怎么理解这句话呢?首先我们知道阶乘函数定义在正整数离散点上,若对于任何一个非整数,无法使用其定义式求出它的值,因此我们需要对其进行延拓—— 最后得到了如下的定义式:
现在我们只考虑其在实数域且 上的情况,毕竟考研只需要这个。
Gamma 函数作为阶乘函数在更广的数域上的延拓,首先它当然满足阶乘函数本来的定义;它具有如下的性质:
那么如何证明这两个性质呢?一般有两种常见的做法:
对于 的定义式使用分部积分法:
显然,第一项为 0,第二项又是 的定义式,故:
就得到了上面性质中说到的递推关系;但是我们现在还缺乏一个初值;对于 :
结合上面的到的递推公式,就可以得到它和阶乘函数的对应关系。
这种做法需要一定的技巧性;首先我们可以进行如下的展开:
对于第一个展开式,又有:
综上所述,可得:
上式右侧的 也可以利用第二个展开式展开为无穷级数:
简单地说,就是在一致收敛域 上,有:
对比系数可得 Gamma 函数的定义式:
虽然上述等式需要在一致收敛域上才成立,但是 才是函数的参数,因此 不受限制。
那么,已经知道了 Gamma 函数,我们应该怎么运用到上述的情况中呢?对于积分 ,我们令 :
因此,我们得到了关键值 。其他的值都可以从这个关键值出发求出;
下面对于几种常见的变换进行示范:
令 ,那么有:
我们就可以使用关键值快速求出这个积分值
同理,令 ,那么有:
关键在于使用换元法将原积分转化成 Gamma 函数定义式的形式。
常用的类似积分的查表。
被积函数 | 函数 | 在 上积分值 | 在 上的积分值 | 说明 |
---|---|---|---|---|
不存在 | 关键值 | |||
不存在 | 反常积分 | |||
不存在 | ||||
不存在 | 可分部积分 | |||
关键值 | ||||
不存在 | 可直接积分 | |||
不存在 | 可直接积分 | |||
部分积分在上面已经进行了推导。
说是速记,其实涉及到了 Gamma 函数的另一种形式;令 :
也就得到了 Gamma 函数的另一种表现形式:
这种情况下和原定义不同;当 的次数增加 2,对应的 Gamma 函数的参数增加 1.
虽然看起来很显然,但是在对于考研中各种类似这个的积分,使用这种形式可以快速建立积分和 Gamma 函数之间的关系,从而使用我们记忆的关键值和定义对积分求解。
原来这玩意不管是在汤家凤的高数讲义上还是在张宇概率论9讲上都有提到啊…… 我学的是个寄吧()
首先来点题目链接:P3327【SDOI2015】约数个数和 - 洛谷 | 2185. 「SDOI2015」约数个数和 - LibreOJ
是 的约数个数,给定 ,求:
这个题要用到它,但是我还不会。所以先随便学学吧!
定义域为正整数的函数称为数论函数。
若 是数论函数,且对于 有 ,那么这样的数论函数称为积性函数。
常见的积性函数有欧拉函数()、莫比乌斯函数()和除数函数()
若积性函数不需要 也能有 ,那样的 就是完全积性函数。
常见的完全积性函数:、
两个数论函数 的 Dirichlet 卷积的定义如下:
卷积运算符 满足运算的交换律结合律以及对于 的分配律;此外,对于满足 的数论函数 ,卷积运算符还满足了等式的性质,即:。
Dirichlet 卷积具有单位元,其单位元 定义如下:
这个单位元也被称为单位函数,它是一个完全积性函数。
结论 1:若 是积性函数,那么 也是积性函数。
结论 2:积性函数的逆元(对于单位元)也是积性函数。
莫比乌斯函数是积性函数。
记作 ;对于一个整数 ,令其标准分解形式为 ,则莫比乌斯函数可以如下定义:
简单地说:如果 有平方因子,那么 ;否则是 , 是 互不相同的质因子个数。
莫比乌斯函数最重要的性质是:
用 Dirichlet 卷积的形式表示,就是 .
令 有标准分解形式为 ,定义 ;那么显然有:
而 中是没有重复的因子的,所以 只需要在这 个本质不同的因子中随意选取组合即可:
为每一项增加一个 ,由二项式定理可得:
显然,当且仅当 时上式取值 ;其他情况下均为 。而 时, 没有任何素因子,故 .
除此之外,我们还有一个结论可以帮助我们将莫比乌斯函数和欧拉函数联系起来:
这个待会再想办法证(
根据定义,稍微修改一下线性筛,我们可以写出下面的代码:
1 | template <int n> auto &mobius_sieve() { |
事实上,线性筛基本可以求所有的积性函数。
对于一些函数 ,若它本身难求但其倍数/约数和好求,那么就可以用莫比乌斯反演来简化其运算。
设有数论函数 ,那么有如下公式:
形式一是标准形式。
我们先利用卷积知识简易证明上述的形式一:
首先,将上式左边看作 ;然后,有 Dirichlet 卷积的运算性质,我们在等式两侧卷积 ,有:
又因为莫比乌斯函数的性质有 ,所以上式的右侧消去单位元,得:,即上式右侧。
或者通过数论变换的方式证明形式一:
数论变换就是反着推导的过程;对于上述关系右侧等式的右侧,代入关系左侧的条件可得:
因为 是 的因数, 和 是对于 的进一步划分,这里交换求和顺序可以得到上式最右侧的形态;观察这个式子的右侧,利用莫比乌斯函数的主要性质,可以将其转化为单位函数的形式:
也就是左侧的求和只有在 时取值 ,结果也是 ,和形式一右侧等式的左侧一致。
我们也可以如法炮制的证明形式二:
将可被 整除的 表示成 ,将形式二的前提代入关系右侧的等式的右侧可得:
然后再进行如法炮制的交换求和顺序: 是无穷的 的因数,再进一步划分 ;再次观察最右侧的式子,并转化为单位函数:
显然,整个求和式子只有在 时才能取到值,且此时的值是 ,和形式二右侧等式的左侧一致。
不知道看到这里,是否对于”莫比乌斯函数是一个和容斥系数相关的函数“这句话有了什么新的理解。
但是一般来说构造一个 颇有难度,那个公式很多时候都意义不明。所以通常的做法都是想办法整出一个 也就是 ,然后通过 来计算它;而这实际上就是莫比乌斯函数的性质——这样说也是十分意义不明,所以我们说一类相对比较常见的问题作为例子:
给定 ,求解:
我们考虑枚举 的取值,于是有了:
那么我们可以对于单位函数的部分代入莫比乌斯函数的性质——有:
对右侧式子进行反演——或者说调换求和顺序,去枚举 的值并将其挪到外层,有:
可以注意到这个时候右侧式子中的 已经不会影响到所需要求和的东西了,相当于对 1 求和:
此时,我们假设一个 :
观察题设的公式和我们的推到结果:
我们把 看作一个整体,它是 的因数, 是对其进一步的划分;那么令 :
至此,我们完成了题设公式的转换,即反演;~~但是这样做的意义,是什么呢?~~当然是推出来的这个式子相对比较好算了!一重求和不比二重求和容易?
上面的部分主要说的是对于含有 的式子的处理方法:弄出一个 然后再利用它的等价式子 代换它,并求出了一个比较通用的“公式”;那么对于这种出现了 的式子,我就是想要使用反演公式设函数套怎么办呢?套路在此:
首先,我们再写一遍莫比乌斯反演公式的某种形态:
一般来说,我们设 为范围内满足 的数对个数, 为满足 的数对个数,那么它们就满足了:
关于 的两种表达式:第一个表达式是利用我们定义的 来定义;第二个表达式是根据我们的定义直接得到的——当 都有确定范围的时候,满足 的数对数量当然是这个。
这样,我们就可以利用前面写的那个莫比乌斯反演公式,得到:
当然,裸求 是不好求的;但是 可以线性筛加维护前缀和, 可以数论分块;于是我们就利用反演公式将不太好求的 简化的好求了一些。
上面的简介仍然是比较抽象,所以还是看几个题:
求
首先原式的求和有区间,所以很显然地将他转化为一个二维差分的形式:
那么原来的问题转化成求 :
参考上面运用小节推的那个比较具有代表性的”公式“——这里 ;那么我们可以根据”公式“来套路的得到:
然后,将它代回那个公式里,可以得到:
现在这个公式已经可以通过 来计算了;但是注意到这里待求和的式子还可以使用数论分块计算,所以实际上上式的时间复杂度是 的。如果不会数论分块可以看这个:数论分块入门 - 七海の参考書 (shiraha.cn)
然后,我们就能写出代码:
1 | constexpr int N = 5e4 + 50; |
遇事不决开 long long
是吧?long long
不是你的电子宠物(
求
令 ,还是很套路地把上面的式子变成枚举 的值:
还是运用“应用”部分得到的公式;我们轻而易举地发现 ,那么套路地设 :
然后还是代回原来的那个包含 的表达式中,可以得到:
令 ,那么:
那么式子就推到这里;上面的式子左边可以数论分块,右边那个东西虽然看起来玄乎但是总归还是可以预处理的:只需要对于所有的质数在范围内的倍数“对数筛”即可,复杂度不明不会素数定理。
1 | constexpr int N = 1e7 + 50; |
所谓条条大路通罗马~~(罗马!)~~,直接用莫比乌斯反演公式也能推出一样的式子,看各自喜好了(
那么,你已经学会了莫比乌斯反演了,快上!(指做开篇的那个题
首先关于这个都不太熟的 ,我们有一个结论:
这个式子为什么是正确的?首先考虑对于一个整数 的标准分解,即 ,约数的个数为 ;这非常的好理解,它的约数必定由它的质因子构成,每个质因子 有 种不同的选法。合数因子本身就是对于这个数字所有的质因子进行这样的选择组合而成;对于 ,我们也可以从这个角度入手考虑:
对于一个素数 ,有 和 ;那么显然 在 中出现的次数是 次,关于这个质因子有 种选法。那么怎么枚举选法呢?显然,若枚举 和 ,得到的 。那么问题就在于两次不同的枚举得到的 的乘积可能实际上是一样的;为了避免重复,我们定义下面的取法:
因此对于每一个因子,在上述规则的限制下,在 中只会选出 种不同的选法,符合我们的要求;而实现这样的限制条件很显然可以通过 来达成目的,因为我们从来没有在 和 中同时选择 .
综上所述,我们要求的式子可以写成下面的形式:
改变四层求和的枚举顺序,先枚举 和 ,那么可以得到下面的形式:
那么就转化了题设的公式,可以基于这个公式进行莫比乌斯反演了。
根据上面的讨论,我们已经得到了一个包含了 形态的公式,现在对它进行操作:
根据 Dirichlet 卷积的单位元的性质,也就是 ,得:
对于最右边这个子式,我们很自然地想到枚举 ;令 ,调换枚举顺序:
右边的双层枚举子式又是典型的求 是倍数的类型,进行套路地转换:
综上所述,我们通过反演将原式转化为了:
那么这个化简后的式子要怎么去求呢?容易发现两个子求和是近乎一致的,可以预先处理;因此我们定义函数 ,于是有:
而实际上, 就是约数个数函数 的前缀和,这个也可以用线性筛求出来维护前缀和;当然如果不想使用线性筛来维护这个,也可以直接分块计算后加起来——复杂度是 ;
综上所述,我们可以用分块和分块求解上面反演得到的式子:
1 | constexpr int N = 5e4 + 50; |
如果用线性筛维护 再求其前缀和 ,则需要引入新的线性筛:
1 | constexpr int N = 5e4 + 50; |
当然,肯定是要把两个线性筛写在一起的;像上面那样写的人脑子多半是有点大病(
于是,这个问题就解决了!
莫比乌斯反演还是比较有趣的;这里列举的也仅仅是最基础最基础的板子题,用来加深对这个公式的推导以及这种方法的理解——也就是说有趣的题还有很多……之后有时间做了再整理一篇吧(
一句话简介:NTT 即快速数论变换,是一种可以在 的时间内完成多项式乘法的算法——的一部分。
可以看我之前写过的一篇文章:基础知识:FFT - 简单入门 - 七海の参考書 (shiraha.cn)
FFT 需要使用复数——这样就无法回避大量的浮点运算,然后精度就会爆炸;但是由于已经证明了在复数域内,具有循环卷积特性的唯一变换是DFT,所以在复数域中不存在具有循环卷积性质的更简单的离散正交变换;因此,我们就提出了以数论为基础的具有循环卷积性质的快速数论变换(NTT):它的特点在于用有限域上的单位根来取代复平面上的单位根。
上面这段话是上网抄的。虽然我现在还理解不了,但是有一件事情十分清楚——和 FFT 利用单位根的性质减少运算量一样,NTT 利用了原根的性质来减少运算量,达到了同样的复杂度。
若 满足 和 ,那么:
对于使得 成立的最小的 ,我们称之为 模 的阶,记作 或 。
反证法:令有 在该范围内并且模意义下相同,那么显然有 ,且 也属于该范围内,和定义矛盾。
显然。否则,把 表示为 ,显然 且满足 ,和性质 1 冲突。
推论 1:若有 ,那么
由欧拉定理可知:若 ,则 。那么由性质 2 可得 。
首先,我们记 ,显然
由性质 2 和题设条件欧拉函数的性质,可得: (好像没用)
那么,有:
假设 ,那么 ,那么
因为 ,由性质 2 可得 ,因此
综上所述,可得:
即 ,必要条件得证。
继承上述的证明,若有 ,那么
由上述证明就可以得到:
因为 ,和性质 2 和 4 可知:
因为 ,所以 ,也就是
综上所述,,
即 ,充分条件得证。
综上所述, 是 的充分必要条件。
由阶的定义可知:
又由性质 2,可以得到:
显然,因为 ,因此:
又因为定义:, 显然是整数。
所以由性质 2 可得:
综上所述:,
由定义可得 和 ,那么:
由性质 2,可得:
设 成立,那么:
又因为 ,可得
即 ,必要性得证。
又由阶的定义:,可以进行如下推导:
由性质 2,可得:;因为 ,所以
同理,可得:;因为 ,所以
由定理可得: 和 ,因此:
由性质 2,可得 ;因此,可证
即 ,充分性得证。
综上所述: 是 的充分必要条件。
,若有 且 ,那么称 是模 的一个原根。
若整数 模正整数 的阶(这要求它们互质)和 相等,那么 是模 的一个原根。
……我一定是哪里变得奇怪了才会想着抄录全部性质并键证它们== 哪天闲的没事干再补全吧()
判断对于一个整数 是否存在原根:
与之相关的一些定理:
简单地说,对于奇素数 ,,有原根的数包括:
对于一个数 ,如果它存在原根,那么首先找到它的最小原根并令其为 ;那么, 的所有原根都可以表示为 , 是正整数并且满足 ,共 个。
最小原根 的大小已经证明是不会超过 的,所以可以通过暴力枚举来确定,然后按照定义要求来验证某个数字是否是原根——但是定义要求 ,我们无法对于每一个备选 都枚举 来计算 ,观察它不和 同余来判定它是阶。
注意到阶的性质 2 的推论 1,我们可以知道 ;也就是说,对于备选 ,如果它模 下的阶不满足原根的要求,而是另有 存在,那么它满足 ;那么,我们只需要检查 的所有真因数就可以找到可能存在的 了,而不需要枚举整个 ;具体地,若 的质因子被记为 ,那么实际上我们只需要检查所有的 即可——它覆盖了所有的真因数的倍数,检查它们等同于检查了所有的真因数。
综上所述,找到最小原根 所需要的时间是 的;利用最小原根 求出所有原根所需要的时间是 的。完整代码如下:
1 | template<int n> |
首先筛出质数,再筛出所有的可能有原根的数——这一步是 的;对于一个有原根的数 ,首先利用筛的结果(当然也可以直接用线性筛直接算好了存起来)根据定义求出 ,然后再利用筛维护的数据(或者埃氏筛直接存起来)分解其质因数存好备用;暴力枚举,并且利用上面说的方法来检查其是否为原根,求出最小原根——这一步是理论 的;最后再利用最小原根生成所有的原根:这需要重复 次,每次使用 检查——这一步是 的。
上面的代码可以通过 P6091 【模板】原根。需要注意虽然最小原根有这个理论界限,但是求的时候只遍历到 似乎会暴毙……
那么如何理解这两个抽象的概念呢?
求解 的值, 是 级别。
暴力是 的,死了;但是 的结果是根据块状分布的,且最多只会有 种不同的值。因此,我们可以利用这个性质得到一个 的算法。
直接地说, 的值在一段连续的区间内具有相同的值,且这个区间具有右端点 。
1 | for (int l = 1, r; l <= n; l = r + 1) { |
上述的写法需要注意的就是 n / l
可能为 0 导致除法除零,一般来说需要特判一下。
那来点模板题。求的是模数和,看起来和上面的整除分块没有什么关系,但是:
这样处理之后,右边的部分仍然不是我们熟悉的整出分块形式,所以还需要进一步处理;
我们已经知道了 在一定范围内具有相同的值,那么在块内我们令 ,那么就可以进行如下形式的化简:
这样,在每一个分块内,左边是整除分块,右边是一个公差为 1 的等差数列;块内只需要一个定值和一个很好求的和,就可以分块来做了:
1 | signed main() { |
好了,现在你已经学会了数论分块了!
题目是要求了 ,所以我们需要转化为下面的形式:
然后,先处理上述式子的第一项;最直接的做法就是拆开化简:
当然,也可以类似于上面求余数和的做法那样直接拆成:
也可以拆出相同的结果。
总而言之,上面的式子就被拆成了一些和上面模数求和一样的形式的组合,只需要分别求出两个互不相关的部分的和就可以算出题目种式子的左边部分;接下来处理右边部分,我们先约定 :
还是一样可以使用模数分块的方法求出。
此外,提一下考研常用公式:
1 | constexpr lll mod = 19940417; |
需要注意的是,,并不是一个质数,所以不能用费马小定理求出模它意义下的逆元;但是 和它互质,故应当使用欧拉定理:
欧拉定理:若 ,则 。
欧拉函数:,表示了小于等于 的正整数和 互质的整数的个数。
标准分解式:将质因数分解的结果按照大小,由小到大排列,并将相同质因数连乘以指数形式表示。
如果一个数字的标准分解式可以写成:,那么:
可以使用上述公式计算欧拉函数。
求解单个欧拉函数值,可以使用 Pollard Rho 算法优化后根据定义求解;多个欧拉函数值则可以使用线性筛求解。
给了 ,求解:
结果对 取模。
这是 CCPC 湘潭邀请赛 2021 的 C 题,无处补题。
首先还是直接把它们乘开:
可以看出前两项是一类的,第三项是另一类的;其中,前两项又可以进行这样的拆分:
又可以分成三项;其中第三项是我们这里提到的整除分块问题,第一项是我们再熟悉不过的“分块”,可以直接计算;那么问题就变成了中间的一部分——在这里我想要说的是这并没有什么特殊的性质(或者是我没有发现),只需要分块再分块然后直接计算就可以了。
那么回到第一组式子的第三项;注意到 和 是互不相关的,所以直接分别计算然后乘起来就行:
只需要分别计算后求和,之后再在模意义下乘起来就得到了。
1 | constexpr llong mod = 1e9 + 7; |
已通过五组测试样例。
本报告涉及到的源代码的位置:https://github.com/ma-hunter/cn_exp
班级 | 姓名 | 学号 |
---|---|---|
软件工程 18XX 班 | XXX | U2018XXXXX |
系统环境 | 软件版本 |
---|---|
Windows 10 Pro 21H1 | WIRESHARK Version 3.4.6 (v3.4.6-0-g6357ac1405b8) |
使用 Wireshark,并学习使用它进行网络包分析。
Wireshark 是网络包分析工具;网络包分析工具的主要作用是尝试捕获网络包,并尝试显示包的尽可能详细的情况。Wireshark 常见的应用如下:
这里,我们通过 Wireshark 来学习常见的网络协议。
包含了从安装 Wireshark 开始到使用 Wireshark 观察一些现象的过程:
Wireshark 的官方网站是:https://www.wireshark.org/,下载地址是https://2.na.dl.wireshark.org/win64/Wireshark-win64-3.4.6.exe
再 Powershell 中输入 ipconfig
查询本机的 IP 信息:
在同局域网下打开另一台设备,启动一个服务器:
查看局域网内的这台设备的 IP 地址:
可以得到的信息汇总如下:
机器 | IP(v4) 地址 | 子网掩码 | 默认网关 |
---|---|---|---|
主机 | 192.168.3.2 | 255.255.255.0 | 192.168.3.1 |
服务器 | 192.168.3.71 | 255.255.255.0 | 192.168.3.1 |
因为只需要这些信息,所以直接在 Powershell 中获取;需要更详细的信息也可以通过网络适配器选项中来获得。接下来使用 Wireshark 捕获两者之间的通讯数据包,并且进行分析。
首先需要先选择监听的网络设备:
然后,就可以开始捕获以太网的通信数据包:
接下来,可以进行捕获通过它的特定报文,并且分析内容。
浏览器访问上面查询到的服务器的地址 http://192.168.3.71
,并且在 Wireshark 中设定限制条件(过滤器) ip.src == 192.168.3.71 or ip.dst == 192.168.3.71
来只捕获来自这两个 IP 地址之间的通信。
高亮的部分就是 TCP 的三次握手。
这是上述三次握手过程中的第二次握手的报文的详细信息:
项目 | 信息 | 说明 |
---|---|---|
源端口 | 80 | 服务器的 HTTP 默认端口,服务确实开在 80 端口上 |
目的端口 | 61577 | 用户浏览器当前开启的用于和服务器通信的端口 |
TCP 段长度 | 0 | 该报文不携带数据 |
Sequence 数字 | 0 | Seq=0 |
Acknowledge 数字 | 1 | 期望收到的下一个报文满足 Seq=1 |
首部长度 | 32 bytes (8) | 8 * 4B = 32 bytes |
标志 | 0x012 | SYN 和 ACK 位为 1 |
校验和 | 0x5426 | 校验和为 0x5426,未验证 |
在 TCP 握手完成之后,服务器将使用 HTTP 协议传输数据到浏览器;我们在紧接着握手完成后面的位置找到了使用 HTTP 协议传输的数据包;可以打开查看其详细信息:
内容 | 信息 |
---|---|
GET / HTTP/1.1 | 操作类型:GET;遵循了 HTTP 1.1 版本的协议 |
Host: 192.168.3.71 | 主机名:192.168.3.71;当绑定域名的场合下会是主机的域名 |
Connection: keep-alive | 连接类型是保持持久连接 |
User-Agent: ... | 用户的客户端信息;这里有个 Mozilla/5.0 (Windows NT 10.0 ...) 说明是运行在 Windows 10 上的火狐浏览器 |
…… | …… |
HTTP 头能塞的东西还挺多的,这里就不全部说明了;当然,这里所包含的项目也未必完整。
网络工程师能通过 Wireshark 做哪些工作?
系统环境 | 开发环境 |
---|---|
Windows 10 Pro 21H1 | CLion 2021.1; CMake 3.19; tdm-gcc 9.3; NpCap SDK 1.07 |
通过学习 NpCap SDK,编写一个网络抓包程序
本实验主要基于 NpCap 完成,所以这里主要是关于 NpCap 的介绍:
WinPcap 是一个基于Win32平台的,用于捕获网络数据包并进行分析的开源库;在 Linux 上也有对应的 LibPcap;目前 WinPcap 已经处于无人维护的状态,对于 Windows 10 有更新的且目前有人维护的开源项目 NpCap。
大多数网络应用程序通过被广泛使用的操作系统元件来访问网络,比如 sockets——这是一种简单的实现方式,因为操作系统已经妥善处理了底层具体实现细节(比如协议处理,封装数据包等等工作),并且提供了一个与读写文件类似的,令人熟悉的接口;但是有些时候,这种“简单的实现方式”并不能满足需求,因为有些应用程序需要直接访问网络中的数据包:也就是说原始数据包——即没有被操作系统利用网络协议处理过的数据包。而 WinPcap/NpCap 则为 Win32 应用程序提供了这样的接口:
SDK 提供的这些功能需要借助运行在 Win32 内核中的网络设备驱动程序来实现;在安装完成驱动之后,SDK 将这些功能作为一个接口表现出来以供使用。
以下介绍了实现后文提到的 demo 所需要使用的 NpCap API 的简单介绍:
pcap_findalldevs
NpCap 提供了 pcap_findalldevs_ex
和 pcap_findalldevs
函数来获取计算机上的网络接口设备的列表;此函数会为传入的 pcap_if_t
赋值——该类型是一个表示了设备列表的链表头;每一个这样的节点都包含了 name
和 description
域来描述设备。
除此之外,pcap_if_t
结构体还包含了一个 pcap_addr
结构体;后者包含了一个地址列表、一个掩码列表、一个广播地址列表和一个目的地址的列表;此外,pcap_findalldevs_ex
还能返回远程适配器信息和一个位于所给的本地文件夹的 pcap 文件列表。
pcap_open
用来打开一个适配器,实际调用的是 pcap_open_live
;它接受五个参数:
name
:适配器的名称(GUID)snaplen
:制定要捕获数据包中的哪些部分。在一些操作系统中 (比如 xBSD 和 Win32),驱动可以被配置成只捕获数据包的初始化部分:这样可以减少应用程序间复制数据的量,从而提高捕获效率;本次实验中,将值定为 65535
,比能遇到的最大的MTU还要大,因此总能收到完整的数据包。flags
:主要的意义是其中包含的混杂模式开关;一般情况下,适配器只接收发给它自己的数据包, 而那些在其他机器之间通讯的数据包,将会被丢弃。但混杂模式将会捕获所有的数据包——因为我们需要捕获其他适配器的数据包,所以需要打开这个开关。to_ms
:指定读取数据的超时时间,以毫秒计;在适配器上使用其他 API 进行读取操作的时候,这些函数会在这里设定的时间内响应——即使没有数据包或者捕获失败了;在统计模式下,to_ms
还可以用来定义统计的时间间隔:设置为 0
说明没有超时——如果没有数据包到达,则永远不返回;对应的还有 -1
:读操作立刻返回。errbuf
:用于存储错误信息字符串的缓冲区该函数返回一个 pcap_t
类型的 handle。
pcap_loop
API 函数 pcap_loop
和 pcap_dispatch
都用来在打开的适配器中捕获数据包;但是前者会已知捕获直到捕获到的数据包数量达到要求数量,而后者在到达了前面 API 设定的超时时间之后就会返回(尽管这得不到保证);前者会在一小段时间内阻塞网络的应用,故一般项目都会使用后者作为读取数据包的函数;虽然在本次实验中,使用前者就够了。
这两个函数都有一个回调函数;这个回调函数会在这两个函数捕获到数据包的时候被调用,用来处理捕获到的数据包;这个回调函数需要遵顼特定的格式。但是需要注意的是我们无法发现 CRC 冗余校验码——因为帧到达适配器之后,会经过校验确认的过程;这个过程成功,则适配器会删除 CRC;否则,大多数适配器会删除整个包,因此无法被 NpCap 确认到。
pcap_datalink
用于对 MAC 层进行了检测,以确保在处理一个以太网络,确保 MAC 首部是14位的。IP 数据包的首部就位于 MAC 首部的后面,将从 IP 数据包的首部解析到源 IP 地址和目的 IP 地址。
pcap_compile
& pcap_setfilter
用来设置过滤器,以避免处理一些无用的包,提高包处理的效率。在本次实验中我们需要将过滤器字符串设置成 ip and udp
,使得我们传入的回调只处理基于 IPv4 的 UDP 数据包;大大简化了解析过程和回调函数的调用次数。
处理 UDP 数据包的首部时存在一些困难:因为 IP 数据包的首部的长度并不是固定的,但是可以通过 IP 数据包的 length
域来得到它的长度;一旦知道了 UDP 首部的位置,就能解析到源端口和目的端口。
从安装 NpCap 到运行 NpCap 示例程序的全部过程;
NpCap 是 WinPcap for Windows 10;它的官方下载页面是 Npcap: Windows Packet Capture Library & Driver (nmap.org);在这里我们需要下载:
安装完成驱动后,再在 IDE 中为项目配置导入 NpCap SDK 文件。NpCap SDK 文件包中包括了使用 NpCap 实现的基本功能的 demo。
项目的文件结构如下:
1 | CMake target: if_list udp_dump basic_dump_ex |
这些使用 NpCap 功能的 demo 的实现代码都位于:https://github.com/ma-hunter/cn_exp
上述代码包括的,使用 NpCap 实现的几项基本功能的运行结果(非截图)。
使用 CMake,构建并运行上述项目中的目标 if_list
:
1 | "D:\Program Files\JetBrains\Toolbox\apps\CLion\ch-0\211.6693.114\bin\cmake\win\bin\cmake.exe" --build D:\Workspaces\CLion\cn_exp\cmake-build-debug --target if_list -- -j 4 |
运行结果如下:
1 | D:\Workspaces\CLion\cn_exp\cmake-build-debug\if_list.exe \Device\NPF_{5C8B26D4-9439-4304-B8FB-48A81CB33CF9} |
于此同时可以看到控制面板下的网络适配器页面;可以看到基本的适配器信息是一致的:
我们的 demo 输出的更多是因为包含了一些被操作系统用户级别隐藏的接口。
使用 CMake,构建并运行上述项目中的目标 basic_dump_ex
:
1 | "D:\Program Files\JetBrains\Toolbox\apps\CLion\ch-0\211.6693.114\bin\cmake\win\bin\cmake.exe" --build D:\Workspaces\CLion\cn_exp\cmake-build-debug --target basic_dump_ex -- -j 4 |
运行结果如下:
1 | D:\Workspaces\CLion\cn_exp\cmake-build-debug\basic_dump_ex.exe |
程序将会运行到被外部中断阻止后才会停止运行。
使用 CMake,构建并运行上述项目中的目标 basic_dump_ex
:
1 | "D:\Program Files\JetBrains\Toolbox\apps\CLion\ch-0\211.6693.114\bin\cmake\win\bin\cmake.exe" --build D:\Workspaces\CLion\cn_exp\cmake-build-debug --target udp_dump -- -j 4 |
运行结果如下:
1 | D:\Workspaces\CLion\cn_exp\cmake-build-debug\udp_dump.exe |
程序将会运行到被外部中断阻止后才会停止运行。
WINPCAP是否能实现服务质量的控制?
不能。WinPcap 可以独立地通过主机协议发送和接受数据,如同TCP/IP;这就意味着 WinPcap 不能阻止、过滤或操纵同一机器上的其他应用程序的通讯:它仅仅能简单地“监视”在网络上传输的数据包。所以,它不能提供类似网络流量控制、服务质量调度和个人防火墙之类的支持,因而不能实现服务质量的控制。
系统环境 | 开发环境 |
---|---|
Windows 10 Pro 21H1 | CLion 2021.1; CMake 3.19; tdm-gcc 9.3; NpCap SDK 1.07 |
利用 NpCap 编写协议分析工具;输出抓取的包和协议分析结构,并统计 IP 的流量(即包的数量)。
功能要求:
运行程序后将捕获的信息输出到标准输出流中。
实验的设计依据的原理,包括协议的概念和 NpCap 的接口使用逻辑。
下面的内容包含了本次实验所涉及到的协议类型的说明。
因为 TCP/IP 协议采用分层的结构,所以网络通信时,要传输的数据在发送端是一个逐层封装的过程;而相应地在接收端则是一个逐层分解的过程;如下图所示:
在接收端的逐层分解,就是上述封装的逆过程;
以太网 II 格式时一种帧格式,应用最为广泛,几乎成为了当前以太网的现行标准;它由 RFC894 定义,如下图所示:
IP 协议是 Internet 的核心协议,它工作在网络层,提供了不可靠无连接的数据传送服务;协议格式如图所示:
ICMP 的全称是 Internet 控制信息协议 (Internet Control Message Protocol)。它提供了很多 Internet 的信息描述服务:例如能够检测网络的运行状况,通知协议有用的网络状态信息;ICMP 是基于 IP 协议的,ICMP 协议格式如图所示:
TCP协议是基于连接的可靠的协议:它负责发收端的协定,然后保持正确可靠的数据传输服务;它在 IP 协议上运行,而 IP 无连接的协议,所以TCP丰富了IP协议的功能,使它具有可靠的传输服务;TCP 协议格式如图所示:
可以看到 TCP 报文段结构由以下的部分组成:
SYN
、ACK
、PSH
、RST
、URG
、FIN
SYN
: 表示同步ACK
: 表示确认PSH
: 表示尽快的将数据送往接收进程RST
: 表示复位连接URG
: 表示紧急指针FIN
: 表示发送方完成数据发送TCP 连接的建立和释放采用了三步握手法,如下图所示:
其过程可以描述如下步骤:
seq=x
)sqe=y
,ACK=x+1
)seq=x
,ACK=y+1
)至此,整个连接建立过程正常结束,数据传输已经正式开始。
用户数据报协议 UDP 是在 IP 协议上的传输层协议,它提供了无连接的协议服务;它在IP协议基础上提供了端口的功能,因此既可让应用程序之间进行通信了。UDP 协议格式如图3.7所示:
NpCap 按照一定的规则提供了 API,和本次实验相关的逻辑如下:
NpCap 的工作周期可以被描述为如下序列:
pcap_findalldevs
& pcap_findalldevs_ex
:获得网络接口设备的列表pcap_open
& pcap_dump_open
& pcap_open_live
:打开设备/打开设备数据包pcap_freealldevs
:释放获得的设备列表结构所占用的内存pcap_compile
& pcap_setfilter
:编译并设置过滤器pcap_loop
:根据设定的数量来循环捕获数据包,并调用指定的回调处理数据包packet_handler
:传入 loop 的回调函数,用来处理被捕获的数据包pcap_close
:关闭 NpCap 句柄(即打开的网络设备)简单地说,使用 NpCap 获得网络接口列表后,我们打开一个设备,并设定循环次数,传入指定类型的回调函数来处理被捕获的数据包,并在程序退出之前关闭设备。
因为上述协议栈分析中提到了,接收端分析包的内容就是一个逆封装的过程,所以我们可以采用逐层递归的方法来设计我们的回调函数;基本设计如下:
(pkt)
:用来传入 NpCap 循环的,帧处理函数(ethernet)
:用来处理以太网协议的部分(ipv4)
& (ipv6)
& (arp)
:处理 IPv4、IPv6、ARP 协议的报头(icmp)
& (tcp)
& (udp)
:处理 ICMP、TCP、UDP 的报文根据设计需求,在判断上一层的协议类型后,将待处理的报文递归给下一层的处理函数即可。
关于代码实现方面的设计:
项目可以分为 handlers
、helpers
和 utils
三个部分组成;
utils
包含了可复用的无后效逻辑的实现,尽量避免重复代码片段handlers
包含了传给 NpCap 的回调函数以及递归下降法实现的逆封装函数helpers
包含了要实现的额外功能,比如本次实验中的流量统计这三个部分分别使用独立的头文件和源文件组成,被 main
模块引用。
项目所需要的类型定义都包含在 definitions.h
中;包含了根据协议的组成而设计的报文和报头的类型定义;因为最后捕获的数据包是指向一片连续内存块的指针,这里的定义必须保证指定的内存块可以完全转化为对应的包类型才行;因此,需要保证顺序和使用的基本数据类型完全和定义符合。
最后,整个项目的文件结构如下:
1 | CMake target: cn_exp |
使用 CMake 加载项目,并构建运行 cn_exp
即可生成目标。
Github 链接:https://github.com/ma-hunter/cn_exp
完成实现上文所述的程序后,运行程序,可以得到下面的结果:
因为上传截图非常的麻烦,这里仅粘贴标准输出的文本。
1 | Hello, World! Hello NpCap! |
1 | Enter the interface number (1-11):10 |
1 | Time: 12:54:00, 129446Length: 60 |
1 | Time: 12:54:00, 148551Length: 106 |
1 | Flow counts: |
1 | "D:\Program Files\JetBrains\Toolbox\apps\CLion\ch-0\211.6693.114\bin\cmake\win\bin\cmake.exe" -DCMAKE_BUILD_TYPE=Debug -G "CodeBlocks - MinGW Makefiles" D:\Workspaces\CLion\cn_exp |
应用WINPCAP能实现哪些网络应用?
实验环境:Ubuntu 20.04 电脑两台,处于同一局域网下。
首先,关于 Nginx 是什么和 Keepalived 是什么就不再赘述;这里仅简单说明实验操作过程:
具体来说可以按照以下步骤进行;我的第一台主机作为常用机,其上已经运行了 apache2
的服务;那么此时如果要再使用 nginx
的负载均衡,就需要先关闭原有的 apache2
以释放 80 端口。之后设置 nginx
的转发规则就可以实现负载均衡了;
即使是像 Ubuntu 这样已经成熟的发行版,也需要安装相当多的工具才能进行服务器的搭建:
1 | sudo apt install net-tools nginx vim |
最主要的是第一项的 net-tools
,它包含了重要的工具 ifconfig
可以用来查看 Linux 网络接口的信息;再完成了安装之后,我们可以运行下面的命令,来启动防火墙和 nginx
:
1 | sudo ufw app list# 列出所有可用的预设配置 |
但是因为我的主机上同时还运行了 apache2
占用了 80 端口,所以需要先处理它,才能重新运行 Nginx;
我在尝试关闭 apache2
的时候遇到了如何关闭都无法解放端口占用的问题;后来通过修改 apache2
的监听端口+重启主机才释放了 80 端口给 nginx
使用。
根据 Ubuntu apache 默认页面,可以知道 apache2
的配置文件位于 /etc/apache2
目录下;首先需要修改 port.conf
中的端口监听信息,然后再根据文件的提示修改 sites-enabled/
下的站点的监听信息。
做完这些重启 apache2
服务之后,就可以释放 apache2
对于端口 80 的占用了;此时运行 nginx
就可以正常启动服务了;
使用 service
命令或者 systemctl
命令检查 nginx
的状态,可以看到如下页面:
此时,apache2
和 nginx
同时运行,分别监听 8080 和 80 端口;分别访问这两个端口,可以在响应头中看到它们的响应分别来自不同的服务器:
至于为什么两个页面都是 “Apache2 Ubuntu Default Page”,是因为我先安装了 apache2
,所以后安装的 nginx
创建的 index.html
被改名了;所以我们需要观察响应标头来判断相应的来源。
首先,我们需要写一个简单的页面,然后再把它们假设起来;这里也使用 Nginx 感觉有点麻烦了,所以使用了 Node.js
提供的简易 HTTP 服务器工具 serve
;如果没有安装,需要先安装:
1 | sudo apt install npm |
之后,我们需要写一个简单的网页:
然后复制两份,分别修改端口的标记;
修改完成后,使用 serve
分别将服务启动在对应的端口,终端最后如下:
在浏览器分别访问两个端口,以确认可以正常访问:
这样,我们就将这个简单的网页部署到了“两个不同的服务器”上了,接下来需要通过 Nginx 来实现负载均衡。
实现负载均衡需要先在配置文件中指定一个 upstream
,其中包含了负载的服务器集群和它们的权重;Nginx 支持你选择特定的负载均衡的算法,如果不指定就是默认的轮询的方法;我们在 /etc/nginx/conf.d/
下创建 nginx.conf
;配置文件如下:
完成配置文件的编写之后,使用 sudo nginx -t
来检查配置文件是否有误或产生了冲突;如果没有意外,则重启 nginx
服务,就可以看到 80 端口会根据设置的权重自动转发到两个简易 HTTP 服务器上。
Nginx 有一个默认的服务器配置,位于 /etc/nginx/sites-enabled/
目录下;这个服务器也是默认监听 80 端口的,会和我们已有的服务器产生冲突,所以要先移除;此外,不需要将我们创建的配置文件软链接到该目录,因为这样 Nginx 会扫描两次这个文件,从而继续得出端口冲突的结论。
可以看到如果不删除这个配置文件,是无法通过 Nginx 的配置文件的测试的。同时,删除的只是链接到 sites-available
目录下的配置文件的软连接,并不会丢失这个配置;可以随时通过创建软连接的方式将这个配置文件拉回来。
接下来,本机访问 localhost
或局域网内访问 http://192.168.3.2/
(本机 IP),反复刷新,就可以看到端口标识根据配置文件中设置的权重反复横跳;
因为不好放视频,所以这里就不放了(
keepalived 的高可用基于多台 Nginx 服务器共用一个虚拟网关,当其中的一台炸了的时候,会自动从这个虚拟的地址中退出,从而保证了对于这个虚拟地址的访问总是有效的(只要有一台服务器在正常运行);因此,这要求至少将这样的 nginx 部署到多个服务器上形成 nginx 服务器集群;因此,首先我们要像上一部分所做的那样在另一个服务器上操作一遍;
比起上面的软件,还需要额外安装 SSH 服务器,以在第一台主机上访问第二台主机:
1 | sudo apt install vim nginx net-tools openssh-server |
安装完成后使用 ifconfig
获得第二台主机在局域网中的 IP,就可以在第一台主机上访问了:
完成了基本软件安装后,启动 nginx
,浏览器访问局域网 IP,可以看到 Nginx 欢迎页面,说明 Nginx 安装成功:
因为这台主机上没有先装 apache2
,所以 NginX 完成安装后就自动启动了;
按照上一个部分部署完负载均衡之后,发现在第一台服务器上无法访问我们部署的页面:
在第二台主机上尝试直接访问 http://192.168.3.2:1919
和 http://192.168.3.2:9191
会发现无法打开页面,再联想到安装 Nginx 之后自动配置的防火墙,判断 1919 和 9191 端口被 ufw 屏蔽;所以在第一台主机上手动开放这两个端口,第二台服务器上的负载均衡就可以正常使用了。
使用 ufw
开放端口可以使用 sudo ufw allow <port>
来实现。
在两台主机上安装 keepalived
并尝试运行:
1 | sudo apt install keepalived |
会发现它们无法启动,因为缺少了配置文件:
因此我们需要为它们添加配置文件;
这一步主要是为第一台主机(主机)和第二台主机(从机)分别增加 Keepalived 配置文件和对于 Nginx 服务器的监控脚本;这使得它们在可以正常工作的状态下连接了同一个虚拟网关,且在检测到 Nginx 服务器出现异常时即使退出,保持虚拟地址的访问总是可用的。
配置文件主要需要指明本机 IP、检测脚本和虚拟网关三个部分,其他部分都可以照葫芦画瓢()本机 IP 在进行前面的操作的时候我们已经都知道了,而检测脚本是可以直接 CV 的~~(bushi~~;虚拟网关需要在对应的主机上使用 ifconfig
查看你的 IP 是由哪一个名字的接口提供的;就像下面的图片中一样——192.168.3.2
这一局域网 IP 是由 enp2s0
提供的,所以虚拟网关的接口是该接口;
除此之外,还需要找到一个没有被占用的局域网 IP 地址作为对外暴露的虚拟地址,以隐藏背后的 Nginx 服务器集群;在这里,我选择的是没有被使用过的 192.168.3.216
;最后的主从机配置文件分别如下:
注意 virtual_ipaddress
的值实际上是 192.168.3.216
这一没有被任何主机占用的局域网 IP,并且和下面从机配置的虚拟 IP 地址保持一致。这里的截图里是错误示范(
因为从机只有一个 USB 网卡,所以 interface
和一般的 Ubuntu 主机的默认网卡不一样。
用来检测 Nginx 服务器是否增长工作的脚本;我不懂,直接网上 CV 来的:
遵循上面的配置文件,我们将这个脚本放在和配置文件同一个目录下,然后使用 chmod
赋予它可执行的权限,并运行一次——如果 Niginx 服务器当前正在正常运行,那么这个脚本会直接退出。
完成这些操作之后,使用 service
或 systemctl
命令启动 keepalived
服务,就完成了部署:
此时,我们使用 ip a
可以观察到在我们指定的网络接口下多了额外的虚拟 IP 地址:
现在,将主机和从机中的任意一个的 Nginx 服务中止,访问虚拟网关还是可以打开这个页面(并且包含了负载均衡);Web 应用的可用性提高了!
首先,我们使用多个服务器部署了我们的 Web 应用;然后使用多台安装了 Nginx 的服务器部署负载均衡——这个集群中的每一台服务器都可以根据设定的权重将来自外部的请求分配到部署了 Web 应用的服务器上;同时,这些 Nginx 服务器通过 Keepalived,通过一个公共的虚拟网关对外部开放;当这些服务器中的任何一个机能出现了故障时,Keepalived 都会通过预先编写的脚本察觉到问题并立即使该服务器断开和虚拟网关的连接直到恢复,保证了来自外部的请求都会通过这个虚拟网关到达正常工作的服务器上,从而实现了高可用。
历史记录丢失
]]>题目链接:https://codeforces.com/gym/103049
习题册文档:https://2020.nwerc.eu/files/nwerc2020problems.pdf
题解文档:https://2020.nwerc.eu/files/nwerc2020slides.pdf
唉,要是国内区域赛的网页也能像国外的这些这样就好了()赛后关闭服务器不能说离谱,只能说非常离谱==
银川站你科表现喜人,但是相应地,对于我这种目前还没有什么奖项的队伍而言的压力也是非同一般。更何况沈阳也因为疫情原因延期了我已经承受了太多,队内总体士气低下,所以就以欢乐赛的形式进行这一次周周练,希望可以找找比赛的感觉——
也许是受了刺激不能再碌碌无为了!,在真机上装了 Ubuntu,这是第一次在较为接近现场赛的环境下作答(
虽然但是,接下来还有下个赛季的邀请赛,还要好好打才行!要加油了!
首先是这次 VP 的成绩:
Penalty | A | B | C | D | E | F | G | H | I | J | K |
---|---|---|---|---|---|---|---|---|---|---|---|
713 | -1 | + | +3 | +1 | + | + |
因为性质上是欢乐赛,所以打的挺懒散的——一直拖到三点半才开,中途甚至还有打了一半队友补了个觉的恶性事件以及打了一半队友泡方便面吃的鬼事情,所以总归是在意料之中;五个题也和最近 VP 这场的几个实力相近的队伍差不太多,所以倒也问题不大(
唯一感觉有点发寒的就是大量的低级错误……只能说是我太久没有写代码没有读英文了,唉,我自裁(
给了俩字符串,其中一个是另一个字符串中部分字符重复后得到的;得到所有重复了的字符(只输出一次)
虽然是模拟,但是实现还是没有那么直接;
1 | signed main() { |
模拟的话就好好地用最正确的做法,不要无谓地浪费时间!当然,熟练度上来了就可以写出更漂亮的实现就是了。
给了 个数字的平均值和其中大小为 的子集的平均值,求剩余部分的平均值。
1 | int n, k, d, s; |
没什么好说的。
重新排序长度为 的数组,使得其差分数组的绝对值递增。
排序之后找到中间一个位置,然后左右横跳就行了;
1 | const int N = 1e5 + 5; |
注意别跳出边界了就行。
大小为 的平面上有不超过七个龙珠;每次你可以询问一个位置 ,交互器会回答其中与你给出的位置直线距离最近的龙珠和你给出的点的距离;如果你询问了一个龙珠的位置,那么视为你已经将它收集,它不再出现在平面上;要求在 次询问内收集所有龙珠。
虽然但是,龙珠只能在整点上;而众所周知,一个圆上的整点数量是极其有限的;所以我们随便问一个点,得到半径之后就遍历所有的可行的整点就可以找到了。更何况这个题时间限制足足给了 9s,还有和 7 一点关系都看不出来的 1000 次询问,我觉得这明摆着就是教我暴力(
1 | const int N = 1e6 + 5; |
但是非常不凑巧的是,我最开始的代码会在某些情况下处理完所有的龙珠之后继续询问,导致一开始 T 的飞起,我甚至一度怀疑我的判断是否合理()只能说蠢到家了 == 实际上理性分析,每一次遍历一个圆,时间复杂度实际上是 的,最多只有 7 个圆但是却 9s 的时间;就算询问次数爆了,也不会超时而是 WA 才对;这样一来怎么想都不可能超时……所以一定是逻辑写错了()看来还是因为最近训练的不够多,导致对于这种问题不太敏感了==
除了这种暴力的办法,我们当然可以想到一些更加“理智”的办法来求解;只是代码会难写许多:
这些想法在比赛时 T 飞了的情况下都有考虑,但是最后几乎都因为代码不太好写而作罢……属实不是很行;实际上第二种思路也写了代码,只是也没有处理这个问题导致同样 T 飞了()
有 个点在一条直线上做匀速直线运动;第 个点的 - 方程是 ;如果某个时刻,有两个点在同一个位置,那么这两个点就会湮灭;问最后可以存活的点的数量,并按照顺序输出。题目保证不会有任何时刻三个或更多的点出现在同一位置。
首先要发现,相撞的点必须是初始位置相邻的点——不然中间那个点又没有撞,却变成小透明让它左右的两个点穿过它并相撞,岂不是很滑稽——题目也限定了不可能出现三个点相撞的情况,所以完全不考虑这个问题。
要是考虑三个或更多点同时相撞的话,这题可就给我整不会力
因此,我们就可以将所有相邻的两个点相撞的时间先算出来,然后塞到一个堆里;每次取出最先相撞的两个点,它们湮灭后,将新成为的邻居相撞的时间计算出来;维护的过程中,可能会出现两个点其中有一个死点的情况,此时要记得直接弹栈不做处理;
然后是最基础的:速度相等的话不可能相撞,直接不入堆;相撞时间是负数,说明它们相背而行,也不管。
1 | const int N = 1e5 + 5; |
观察代码,大概能发现有一个名字怪异的变量 _t
;因为这个题的一发罚时就交在它上——这个变量本没有什么用处,但是却屏蔽了全局空间里的 t
,带来了错误…… **写代码要养成好习惯!**以后这种没有用处的变量都要用这种不容易输入的名字,相当于直接屏蔽了((
假设原子内有 个中子,那么它分解后释放的能量有如下关系:
现在给定 以及对应的数组 ,进行 次询问:每次询问有 个中子的核释放能量。
首先,如果这个 没有那么大,那么可以使用 DP 维护出上界之内所有核释放的能量;但是因为题目里这个 极端地大,所以这种做法 pass(
让我们贪心地考虑:如果存在某个 使得 最小,那么为了使得最后的分解能量尽可能地小,我们要尽可能的选择分解得到 ;但是这样存在一个问题:用 并不能凑出所有的范围内的正整数;因此,我们需要选择其他的核,但这样就需要抉择优解了。
不难想象,当 大到一定程度之后,它一定可以被表示为一些 和一些其他的核的组成;相应地,在一定范围内, 可能存在不使用 的分解方式,而这种方式最优。因此,一种比较理智的思路就是找到这样的一个范围,在这个范围(如果可以接受)内使用严谨的 DP;而在这个范围外,则贪心地使用 直到剩余的中子数落到范围之内,再使用范围内存储的值拼出答案。
这里,首先给出结论:在不少于 个正整数中,一定存在一个子集使得子集的和被 整除;对应到这个问题里来看,就是这个子集的所有核都可以换成 ,从而使得单位能量更低;那么接下来来说明这个结论的正确性:
首先,我们将这 个数字按照某种方式任意排序,然后对于得到的数组求前缀和;接下来,我们使用 表示对于这个数组长度为 的前缀和,我们就得到了一个长度为 的 数组;然后,我们在 的意义下对于数组 分类:
至此,我们已经证明了给出的结论;对于这个题目而言,这意味着只要 不可能被分解成少于 个数字,那么就一定可以被 代替;而 个数字可以组成的最大的数字是 ;
是不是做到这里,这个边界就找到了呢?我们先考虑一个很显然的情况:我们能否将 分解成 个 呢?显然,除非 ,否则一定会分解出一个非 的数字,因为最后一次分裂一定是 分解为 ;也就是说对于 的分解序列,如果它满足本题要求,那么一定存在 和 使得 ;这一部分无关最小能量,是这个分解符合题意的基本条件。
那么,我们要怎么修正我们的严谨边界呢?因为这个要求也最多影响到两个基础分裂结果,所以只要在我们的边界上加上它就可以了;两个基础数最大是 ,我们现在的边界是 。
1 | const int N = 110, M = 12315; |
其实完全没有必要想这么多;因为著名的 问题~~(大概是这个式子吧,不排除我记错了的可能)~~我们都有常识:使用一些正整数组成更大的正整数,在某个边界之后可以组成任何正整数;虽然不完全一样,但是这并不影响我们想到上面的猜想;至于这个边界,去取一个足够大但是又时间足够求出的范围就完全没问题()就算再蠢想不到贪心 ,检查所有的元素作为首要替代也可以通过此题…… 只能说确实拉了==
有一个 的棋盘,有 个二维向量;现在
Alice
和Bob
各有一个棋子,在不同的位置;接下来他们按顺序选择下面操作之一执行:
- 从 个向量中带放回地选择两个作为位移并顺次执行;吃掉经过位置的敌方棋子
- 瞬移到某个没有放置棋子的位置
- 什么也不做
Alice
现在想知道他是否可以吃到Bob
的棋子,或者在吃不到的情况下是否能通过瞬移回避被Bob
吃掉。
首先考虑 Bob
是否能被 Alice
抓到;如果要暴力搜索那复杂度是 的,大概是过不了的;但是对于这种搜索有一种很经典的做法就是两端搜索——这样复杂度就是 的;对于两个点求出合法的 个转移中点,然后进行匹配即可。
那么现在应该如何确定是否可以瞬移到 Bob
抓不到的地方呢?对于每一个位置都进行测试?未免也太蠢了一些;考虑以下从 种操作中选出两个,可以有 种不同的排列;然而除去自交的 种,剩下的部分都是对称的,可以到达的位置是相同的;所以总共可以到达的位置是 种;观察式子,不难发现:
Alice
无处可逃,但也仅限于少数情况。Alice
也总是有的放矢。所以一种理智的方法就是当 较小的时候,就暴力地检查每一个位置是否可达;否则,则可以进行有限次数的随机坐标;因为 较大的时候,在极端的情况下不可达的位置占比接近 ,如果进行 次随机,那么找不到不可达位置的几率将无限接近 ;事实上,在实现上,我们也可以设置随机直到找到一个逃逸位置后中断。
1 | const int N = 1e5 + 5; |
只要随机的合理,那么随机也是算法!
有一个环形公路,路边有 个风景;现在有三个人准备从某个景区出发,顺时针绕环形公路欣赏所有风景,且彼此不希望被其他人打扰;现在已知风景 的路途需要用时 的时间(当然,),第 个人想要在第 处风景停留时间 ;当一个人看完了所有的风景,他将瞬移回酒店;
现在要调度三人的出发景点以尽量满足他们的愿望;求是否存在一种安排,满足他们不会互相打扰;
考虑以下暴力怎么做:我们枚举安排 ,然后在线性时间内模拟出三人的行程安排,再用线性时间去检查是否发生了冲突;显然,这样做的复杂度是 的,不合题意;因此,我们可以考虑使用一些方法来加快这个模拟的进程,或者说省去没有必要的暴力。
注意到如果三个人发生了冲突,那么一定可以规约成其中两个人的冲突;而暴力枚举两个人行程的冲突的情况只需要 的时间复杂度;因此,我们可以对于每两个人暴力枚举一次,并记录冲突情况;然后再遍历三人的安排情况,就可以 地求出三人的冲突的情况,从而解出。
1 | const int N = 405; |
突然感觉这一场是不是实际上挺水的……这也是聪明的暴力啊(
你要速通一个游戏,它目前的记录是 ;你现在设想了一种通关方式,如果一切顺利只需要 的时间;这种通关方式路径上有 个高难点,对于第 个点:它预期出现在 时间,你有 的把握可以成功通过,如果失败你将浪费 的时间。
因为是你一个人在刷记录,所以如果你一轮失误过多,你会考虑重置游戏,从最初开始进行进程;但没有人想重复劳动,如果失误在可控制的范围内,你也会考虑就这样继续游戏;现在要求出你能刷新记录的最少的期望时间——这个时间是你从开始尝试直到打破纪录所花费的时间。
在开始之前,我们先做如下定义;游戏开始时存在一个事件 ,其中 ,;当然,惩罚时间没有意义;游戏结束时存在一个事件 ,其中同样有 ,;此外,我们计算两个事件之间所需要消耗的时间 ;特殊地,有 。
此外,考虑我们对于重开和继续游戏的抉择——我们有一个最大可容忍的犯错时间 ;我们暂且将它记为犯错边界 ;如果我们犯错的时间尚未超过它,那么可以考虑重开和继续游戏的最佳预期时间;否则,我们只能重开,因为继续游戏已然毫无意义。
因此,我们可以考虑一个 DP:令 表示即将面对第 个事件,在这之前已经浪费了 的时间的情况下,速通还需要的期望时间;显然,我们要求的答案就是从最开始进行游戏的时间,也就是 ;根据这个定义,我们也不难想到下面的转换:
实现上,不难想到这样的 DP 应该从后向前更新答案,最后求出 ;但是注意到转移的过程中是需要 作为参数的,而这个值是我们最后才能求出的,形成了一个循环;对于这种类似方程的情况,我们可以考虑使用二分的方法,猜测一个 作为参数先代入转移,并与最后求出的 对比,直到两者足够接近。
因此,将上述的转移套上概率 之后就可以写出一个对于 的 check 函数:
1 | using trick = tuple<int, double, int>; |
这种做法——二分解方程——确实算是一个比较有意思的技巧;学到了许多()此外对于概率论 DP 的题目而言,还是要注意不要陷入了误区——比如考虑重来是不是未来成功的可能性更高更加省时间这种;因为这样带来的优势无法量化,所以也就无从考察;而概率或者说是成本则是一个理智的量化标准——不用想别的,只要考它就好(
当然,上面说的这些也非常的主观……只能说之后有了更好的理解再来补充说明吧(
有一张图,首先你需要从中选出一些点组成一个
non-self-intersecting path
,然后将剩下来的点平均分成 A 和 B 两组。要求整张图不存在任何一条边,使得这条边的两个端点一个属于 A 而另一个属于 B。
没有太看懂题,,只能说英语或者说理解能力属实不是很行()最后对着答案才算搞懂题目是什么意思==
在图中怎么样动态地维护一条边?当然是 DFS 了啊!在图中怎么样保证两种节点不直接连接?当然把连接到链上的一棵子树全部划给一个组啊!于是,就有了标准题解的构造方法:
这相当于我们使得 path 总是经过我们选择的根节点,然后将不同的子树根据 A 和 B 实时的大小关系分配给它们;因为总是从下而上分配,所以粒度是小的;又因为 DFS 是单点步进的,所以得到的 path 是一条简单路。而从过程的最开始,A 和 B 就不可能公用一条边。
1 | const int N = 2e5 + 10; |
如果要是需要更加不巧妙的方法,也可以维护子树大小和,然后保证递归处理分裂的只有一颗子树(因为 path 必须是简单链)——这可以通过最后处理重儿子来保证。
有一些方块堆的高高;现在你每次操作可以推/拉一个方块,使得它的上面,右边发生一个位移;位移之后收到重力影响方块可能会下落。水平空间无限,问你使得所有的方块落到平面上需要最小的操作次数。
注意:这里的推拉操作相对之前的类似的题目要随意许多;你可以拉动任意连续的方块,也可以从夹缝中推动方块(而不需要这一面空出来)
瞄了一眼题解,这个题竟然还是个最短路……麻了麻了,想摸了,有时间在补吧()
补了这么多题目之后,感觉这套题其实真的不错——指的是比较适合我们这种基础不扎实的队伍;因为绝大多数的题目都是意在考察思维能力,而不是考察某种数据结构或者是知识点;如果区域赛是这种形式的,我们发挥正常的可能性也相对而言是更高的。当然,这一场还是很拉就是了(
此外,队伍的配合也存在相当的问题——我能明显的感受到我和队友之间羁绊的不足;因为英文阅读能力低下的原因,我基本也只能被动地听队友给讲题意,但是这样就会因为不默契导致交流效率的低下()此外,我个人的精神力也十分弱小——或许我真的应该正视——我能不能够集中注意力地坐五个小时还保持思维的敏捷性——这个问题了……
更多的话也不想说了;也许是因为各种各样的事情,这个月可以说过的很摸了——上一篇博文还是三周之前……回头月度总结的时候再哈好说说吧()
]]>实际上这篇水文也写了一个星期,只能说不愧是我
为了能够更好地模拟 XCPC 大多数区域赛现场的比赛环境恰好又受到了金川的刺激,有因为手头边恰好有一个笔记本上拆下来的 SSD,所以就决定这么给台式机上整一个 Ubuntu 的双系统了。
现在的 Ubuntu 官方镜像已经十分智能了,基本上官方的安装向导已经可以满足绝大多数情况下的要求了;所以这里也就不没事找事了;需要准备两个硬盘——一个一般的 U 盘用来制作启动盘,另一个速度快一点(至少得是移动硬盘)的硬盘作为安装目标盘。
制作启动盘就是先从官网下载镜像,然后用免费的 Ultra ISO 写入硬盘镜像即可;制作完启动盘之后先关机进入 BIOS,暂时关闭安全启动——因为这会阻止我们从安装介质启动电脑;然后再重启,进入临时启动菜单,从制作的启动盘启动即可;
第一次进入系统,Ubuntu 需要先检查各种文件,所以会需要较长的时间;之后就会进入到熟悉的安装界面——然后对着向导点点点就行了;分区什么的也非常智能,安装向导已经预制了双系统和全新安装两种默认配置,只需要拖动 GUI 组建就可以完成分区——当然,swap 什么的它都帮你做好了。如果有网络的话,可以直接在安装的时候选择安装必要的驱动,这样也省的进了系统之后还要手动((
安装完成之后,先进 BIOS 重新打开安全启动,再将默认启动顺序改回先 Windows 再 Ubuntu;这样每次进 Ubuntu 只需要先插上 SSD,然后进临时启动菜单选择 Ubuntu 的引导就可以了。
以下部分的内容都是个人偏好的配置,仅供参考()大概会持续更新把(
GNOME 桌面的设置已经提供了相当多的配置选项;直接修改既方便,也直接影响了使用体验;下面的内容就设置的每一个大栏目下要做的事情简要的说明一下:
声音和媒体:如果你使用的是 Windows 键盘,那么可以将键盘上的媒体键在这里手动绑定——比如播放/暂停,上一首下一首和调节音量这种。
截图:和 Windows 的习惯不一样,但是个人觉得功能比 Windows 全多了;可以把对应的公共按钮调成和 Windows 一致的按钮(比如复制(选区)截图到剪切板换成 shift
+ super
+ S
)
这是个人关于屏幕截图快捷键的偏好,如果更新了大概会换掉这张图吧(
还有要提一嘴的就是,Ubuntu 的截图,虽然看起来很高级(比如这些截取窗口,都是直接绘图甚至还保留了窗口阴影的透明度),但是并不能实现“抓拍”——并不是按下 Print
键就会截图==
主要是为了设置缩放;就算是 2018 年的电脑,一般都大概是笔记本 1080p 或者是台式机 1400p;不管是哪种 Windows 设置的都是 125% 缩放;但是 Ubuntu 的分数缩放做得很怪,所以如果怕麻烦的话 100% 也挺好()否则,要先开启下面的 Fractional Scaling 才能选择 125%
看到这个选项没被汉化,下面还有 May increase power usage, lower speed, or reduce display sharpness
就觉得不太对劲;这种不好的预感主要体现在各种第三方软件上()因为实际上 Ubuntu 似乎只会整数倍数缩放,分数的缩放是先将你的逻辑分辨率放大到 2 倍下的对应尺寸,然后再压缩到你物理屏幕上的——比如我的电脑两个屏幕都是 2560*1440 的,开启 1.25 缩放就是先将逻辑分辨率设为 4096*2304(也就是 1.6 倍,两倍缩放下的 0.8),再压缩到物理分辨率来的;所以不管是显卡驱动还是其他应用程序(甚至系统自带的截图)“看到”的分辨率都是 4096*2304;此时,如果应用程序不支持缩放,那么 100% 的缩放在屏幕上看起来就只有原来大小的 80%,十分痛苦==
强烈建议选择 Dark;如果默认,那么 VS Code 就会黑色窗口下有白色工具栏;白色的话不是很适合写代码
总而言之要选择纯色()另一个想要吐槽的就是 Dock 栏不能像 Windows 一样一个在左边一个在右边==
自动隐藏 Dock 栏总归还是不太方便,更何况 GNOME 的 Dock 没有那么舒服的可以被调出来,所以最好还是一直显示;和 Windows 看齐,将 Dock 栏的图标大小调到 32*32
就不会显得那么占地方了。
Ubuntu 官方镜像的 apt
使用的已经是国内访问起来较为舒服的源了,所以不需要换源,使用 sudo apt install zsh
安装 zsh
;
完成之后,运行对应的脚本以安装 oh-my-zsh
:
1 | sh -c "$(curl -fsSL https://raw.githubusercontent.com/robbyrussell/oh-my-zsh/master/tools/install.sh)" |
之后输入密码修改默认终端为 zsh
即可;Ubuntu 20.04 似乎要求重启之后,默认的终端才会自动进入 zsh
.
为了方便以后使用,这里再把安装 powerlevel10k 的操作复述一次:
git clone https://github.com/romkatv/powerlevel10k.git $ZSH_CUSTOM/themes/powerlevel10k
~/.zshrc
:ZSH_THEME="powerlevel10k/powerlevel10k"
zsh
,自动或手动使用 p10k configure
更换终端主题风格,详情参考其文档然后,我们需要为 zsh
安装最低限度所需要的插件:zsh-syntax-highlighting 和 zsh-autosuggestions
运行下面的命令以安装 zsh-syntax-highlighting:
1 | cd ~/.oh-my-zsh |
克隆 zsh-autosuggestions 的源代码:git clone https://github.com/zsh-users/zsh-autosuggestions ${ZSH_CUSTOM:-~/.oh-my-zsh/custom}/plugins/zsh-autosuggestions
修改 ~/.zshrc
:plugins=(git zsh-autosuggestions)
重启 zsh
或者打开一个新的会话,就会发现插件已经成功应用了
安装 zsh-syntax-highlighting 之前要先进入一个合理的目录,用来存放其源代码。
此外,Ubuntu 默认的字体并不支持 Powerline 的众多图标;解决方法就是更换字体——既可以使用上一篇文章中介绍的 Hack Nerd Font,也可以用 Ubuntu Nerd Font 不过字体图标比例感人;下载后安装,然后在终端中新建配置文件,选择字体即可。
如果觉得一个个安装这些字体文件很麻烦,可以先在终端中将这些字体文件复制到 /usr/share/fonts/
下的某个子目录中,然后再运行下面的命令刷新字体缓存即可:
1 | sudo mkfontscale |
这样,应该就可以在其他的程序中看到这些字体了;最终终端看起来就像下面这样:
现在,我们默认使用 zsh
作为默认终端,而作为 Linux 终端,zsh
也支持使用 alias
指令为一些命令增加别名;如果我们将这些 alias
指令放在 zsh
的 rc 文件中,这些别名就会在 zsh
启动的时候装载,我们就可以直接使用了;
zsh
有两个 rc 文件;一个全局级别的 /etc/zsh/zshrc
,修改它需要 su 权限;另一个用户级别的 ~/.zshrc
,可以方便的修改;为了方便,我们都修改用户级别的 rc 文件:
1 | alias cls="clear" |
值得提一嘴的是 GNOME 默认的文件管理器是 Nautilus file manager
,但是 Windows 上默认的是 Windows 资源管理器(explorer
);为了不去记这个奇怪的名字,我们可以制定一个别名== 别的就持续更新之后再说吧!
当然,既然是为了用来打 XCPC 而准备的 Ubuntu 环境,那么开发环境当然是必定不会少了:
首先是一些基本的程序,Ubuntu 未必全部预装:
1 | sudo apt install wget curl vim git |
然后就是一些编译工具,主要就是 CMake、OpenJDK、Python 等等
1 | sudo apt install build-essential cmake |
Ubuntu 内置了 python3
,但是不能通过 python
命令唤起;我们可以为它创建一个软连接:
1 | sudo ln -s /usr/bin/python3 /usr/bin/python |
这样执行 python
和 pip
就会默认运行 python3
相关的程序了。
除了从官网下载之外,还有一种相对比较简单的方式,可以方便地通过 apt
下载并安装 Node.js:
apt
安装 npm
:sudo apt install npm
npm
安装 n
—— 一个用来安装 Node.js 的工具:sudo npm install n -g
n
安装最新的 LTS 版本 Node.js:sudo n lts
PATH="$PATH"
或者重开终端,刷新系统变量,就可以检查 node -v
了这个工具会自动从官网下载 tar.xz
包并自动安装最新的 Node.js。
只有开发软件当然还是远远不够的;这里列出的大多数日用软件都只需要按照官网的或者是网上到处流传的说明安装就不会出什么问题,这里只是作为列表列出防止忘记(
Firefox 当然很好用,但是 Edge 承载了大量的同步书签之类的,而且 Chromium 内核的浏览器的表现也确实比 Firefox 要好;所以也确实得装一个:
官方网址:https://www.microsoftedgeinsider.com/zh-cn/download
下载 *.deb
包之后使用 sudo dpkg -i
安装即可。
可以从 snap
商店安装;但是听说 snap
安装的版本无法使用中文输入法,所以还是要从官网上下载包来手动安装;前半句话我实践了,是真的;但是后半句话懒得测试了,测试完了再更新文章吧!
个人偏好:安装了插件 C/C++
、Chinese (Simplified) Language Pack for Visual Studio Code
、vscode-icons
、CMake Tools
。
1 | { |
然后这里是自用的 VS Code 的配置文件;如果之后有改动再更新这里吧!
用来快速地安装所有的 Jetbrains IDE,教育版可以登陆账号之后一键全部激活;
官方网址:https://www.jetbrains.com/zh-cn/toolbox-app/download/download-thanks.html
下载的是个 *.appimage
,给执行权限之后打开就会自动安装到电脑,之后就可以使用它安装 JB IDE 了;
它默认将 IDE 安装到 ~/.local/share
目录下,所以如果要创建快捷方式,可以在 ~/.local/share/applications/
目录下看到对应的 *.desktop
文件;复制到桌面允许运行即可。
因为基本都没什么坑,所以这里就列个表记录一下算了:
软件名 | 来源 | 类型 | 备注 |
---|---|---|---|
Typora | https://typora.io/#linux | apt | Markdown 编辑器,还可以搭配 PicGo |
QQ 音乐 | QQ 音乐下载页 | deb/appim行age | |
FileZilla | snap 商店 | snap | 功能完备的 FTP 客户端 |
百度网盘 | 客户端下载 | deb | |
Sublime Text 3 | snap 商店 | snap | |
XMind 2020 | 下载页面 | deb | 思维导图软件 |
截至 2021-05-25,这些软件更新的时间还是比较阳间的。
当然,毕竟是把整块 SSD 都用来作为 Ubuntu 的系统盘了,所以不搞点花里胡哨似乎也不是很对劲;
官方下载网址:https://music.163.com/#/download,在某个不起眼的小角落可以下载 deb 包;Ubuntu 20.04 双击大概是装不了的,乖乖 sudo dpkg -i
吧()
缓存首次启动就会发现有缩放问题,修改 desktop
文件(位于 /usr/share/applications
)里的执行命令 --force-device-scale-factor
不起作用,但是直接终端运行带上这个参数有作用(?);分析发现执行的实际上是一个脚本 /opt/netease/netease-cloud-music/netease-cloud-music.bash
,而且是一个 Qt 程序;因此考虑使用针对 Qt 程序的办法)——在执行脚本中加入一个环境变量 QT_SCALE_FACTOR
表示缩放,如下图:
之后再安装一个依赖 sudo apt install libcanberra-gtk-module -y
,就可以正常使用网易云音乐了。
这种做法对于所有 Qt 程序都有效,只要你能够找到程序的运行脚本在哪里(
可以官网下载 deb
包,也可以直接使用 apt
安装;
首次启动大概缩放是有问题的,而且万不用的 --force-device-scale-factor
不起作用;此时需要打开它的 desktop
文件,修改它的启动命令,在前面加上环境变量 GDK_SCALE=2
这样的修改大概还是不起作用的,但是非常玄学的是进去之后,调成大屏幕模式,再退出大屏幕模式,之后的缩放就一直正确了;不说奇怪只能说是非常奇怪(((
GDK_SCALE
听说好像是 Chromium 相关的参数,看来 Steam 客户端的 GUI 可能是浏览器套壳)
一些各种各样的小问题以及一些小操作:
首先,可以从 Windows 的字体目录 C:\WINDOWS\Fonts
目录下拷贝字体到 /usr/share/fonts
目录下,然后运行 sudo fc-cache -fv
来生成新的字体缓存。
这里先放置一些必用字体的下载链接吧;但是只是这些是不够的,不过日后遇到了问题再说吧!
Ubuntu 和 Windows 对于 BIOS 时间的处理方式不同;Ubuntu 认为 BIOS 上的时间就是 UTC 时间,然后会根据用户的设置的时区加上对应的偏移时间得到系统显示的时间;而 Windows 认为 BIOS 上的时间就是本地时间,不会做任何处理就会直接读取到系统上显示给用户——这导致的问题就是 Ubuntu 同步时间之后,Windows 的时间慢了八个小时。
因此,作为副系统的 Ubuntu 需要适应 Windows 的时间管理方式:
1 | sudo apt-get install ntpdate |
这样,再从 Ubuntu 进入 Windows 后的系统时间就是正常的了!
Ubuntu 上有两个很好用的命令行工具,可以用来压缩图片文件的大小;分别是 jpegoptim
和 optipng
:它们都可以通过 apt
来安装,并支持使用参数控制输出的质量;
可以使用 find . -type f -name "*.png" -exec optipng {} \;
来递归的处理目录下的图片。
此外,文件管理器 nautilus 也有一个不错的插件 nautilus-image-converter
可以用来调整图片的尺寸,并且也可以方便地通过 apt
安装;安装完成后运行 nautilus -q; nautilus &
重启文件管理器服务,找到任意一张图片,右击打开上下文菜单,可以看到出现了 Resize Image
的选项:
单击打开之后,就可以缩放图像:
此外,如果还需要对于图像进行基本的编辑(比如裁剪),还需要安装 gthumb
;它也可以通过 apt
安装。
因为需要访问校园网内网,所以需要设置代理(迫真
还不会搞,等以后会搞了再更新吧 ==
有些东西感觉放在上一板块显得过于冗长,但是感觉又不能不写;所以就开了一个这样的分块
Linux 没有一个明确的桌面标准,但是现在大家都在用的发行版大多都遵循了类似的规则;比如 GNOME 或者大家耳熟能详的 KDE Plasma、xorg 等等;它们显示的图标都是一类赋予了可执行权限的 *.desktop
文件;这些文件一般都在 /usr/share
的目录下,有的也会在 /opt
目录下;如果你通过 Jetbrains Toolbox 来安装 JB 全家桶的话,那么它安装的默认地址是 ~/.local/share
下;snap
商店有的时候也会安装在 /snap
目录下。
一般创建快捷方式的方法,就是到这些目录下,复制出对应的 .desktop
文件,然后属性给可执行,并允许执行即可;使用文本方式打开这个文件,还可以看到它实际运行的命令;在有必要的情况下可以对其进行修改。
也许之后可以使用一些办法得到 GNOME 桌面获得的应用程序列表的文件来源(
前面的内容已经或多或少地提到了 Ubuntu 桌面混乱的缩放;在我的桌面环境下,实际只有 2560*1440 的屏幕上实际上跑着 4096*2304 的分辨率,100% 的程序肉眼可见的只有 80% 的大小。不仅对于没有很好适配的应用程序,对于浏览器呈现的页面来说也是这样——
Ubuntu 是有一个算是比较通用的强制缩放的开关 --force-device-scale-factor=2
;可惜经过我的尝试,对于绝大多数软件来说都不起作用((
在双系统的 Ubuntu 下,是可以通过自带文件管理器看到 Windows 下的盘符的(实际上是物理硬盘),并可以从其中读取文件,但是不能写入——这并不是因为 Ubuntu 不支持 NTFS——实际上,你插入任何一个 NTFS 移动硬盘都是可以正常读写的;导致 Ubuntu 将系统硬盘看作只读文件系统的原因是因为 Windows 默认开启快速启动,导致硬盘上写入了缓存,这会影响到 Ubuntu 对于它的访问。
当然,毕竟 Windows 是主要操作系统,为了 Ubuntu 稍微舒服一点就大改 Windows 的设置显得非常的没有必要,所以这里也就不再多作评论了((
有待考据……如果发现什么问题之后再来更新吧!
一般而言,在 Ubuntu 里有几种安装程序的途径:
本 APT 具有超级牛力
非常熟悉;通过它可以方方便便地安装很多命令行应用和非命令行应用;通过增加 GPG 和仓库可以使得它更加广泛,可以安装更多的程序;比如 deepin-wine
就可以通过引入仓库后使用 apt
安装;
在使用 WSL 的时候,它自带的 apt
其实并不全面,很多软件都需要导入外部的仓库才能安装——比如说 yarn
又比如说 openjdk
等等;但是 Ubuntu 20.04 的 apt
已经很全面了,说实话确实省了许多功夫。
dpkg
用来安装 *.deb
的包;很多时候下载的 *.deb
文件双击并不能正确安装;这个时候就需要在终端中 sudo dpkg -i
来安装它;当然,运行起来是什么样就保证不了了((
诸如流行的 *.appimage
以及一些 *.run
或者 *.app
的文件,只需要在属性中赋予它可执行的权限就可以执行,并打开一个 GUI 窗口;有的是直接原位置运行了,有的会直接给安装到电脑中。比如 Jetbrains Toolbox 和 Qt 等程序。
一些不那么常用的,但是确实有可能用到的命令;硬要说肯定是可以围绕着它们写文章的,但是因为我很懒等各种客观因素,这里就先放在这里——以后补充说明的时候再更新吧!
命令 | 说明 | 备注 |
---|---|---|
nvidia-smi | 查看 Nvidia 独立显卡的运行状态 | |
optipng <file> -out <file> | 使用 optipng 压缩图片的文件大小 | 需要先安装 optipng |
scrcpy | 使用 adb 使得 Android 手机投屏 | 之后再开篇文章讲 |
实际上它们如果变得常用了,我很大概率会给它们弄个别名吧((
这篇文章是在安装好之后的 Ubuntu 上写的;只能说是非常难受了…… 一看输入法还是 2011-2012…… 只能说很努力了,这个年份的微软拼音还在玩泥巴呢((
不过,虽然打字万分难受,但是还是把这篇文章写完了(
当然,毕竟是 Ubuntu,想要用的和 Windows 一样舒服还是有一些距离的(
名称 | 说明 |
---|---|
有道词典缩放问题 | 经典不适配系统缩放导致观感 80%;通用的做法无效 |
Typora 不显示在 Dock | 不仅仅是从命令行启动的 typora ,从 GNOME 启动的也会先不显示图标 |
Wine TIM 无字体 | 安装了多个字体均无效,当然缩放也有问题( |
如果以后有解决办法,再在后续的更新中说明吧!
记录了本文件从创建以来进行的更新:
日期 | 更新内容 |
---|---|
2021-05-19 | 创建了本文档,更新了网易云音乐的缩放调整方法,更新了 Node 和快捷方式的说明 |
2021-05-25 | 增加了终端的配置说明,增加了一些截图,整理成文章 |
大概下次有机会再整一个配置文件的备份仓库吧,Jetbrains IDE 每次重新配置老实说挺麻烦的((
]]>补题链接:https://codeforces.com/contest/1519
竟然足足一周没有正经的训练了…… 唉,这样可不行啊
这一场的话 ABC 肯定是没啥问题的,D 能不能做出来心里还真的没底;毕竟正式 CF 也不会允许我打了三个题然后出去吃顿饭什么的,所以只能说悬(
有手就行没手不行(
1 | signed main() { |
记得开 long long
(
稍微手玩一会就会发现其实答案是固定的值;所以只需要计算答案之后比较大小就行:
1 | const auto null = nullptr; |
手玩正方形,发现固定显然;再手玩长方形,发现多余的部分安哪里都一样,所以(
有 个学生,每个学生都有自己所属的大学 和实力 ;设一组有 人,第 所大学一共有 人:那么每个大学将会派出自己所属的实力最强的 人参加比赛;设比赛的影响力是所有参赛者的实力之和,求出 时赛站的实力。
对于某个大学,它只能对 的比赛做出贡献——直接计算后累加就可以了;但是因为还要对每个大学的所有学生的实力进行排序,所以总复杂度是 的。
1 | const int N = 1e5 + 5; |
答案其实挺显然的,但是我还是想了半天(
给了长度为 的数组 和 ,现在你可以翻转数组 的一个连续子区间,问 的最大值
暴力枚举反转区间 ,时间复杂度是 的;考虑字符串算法中处理回文串的一些做法,我们可以枚举中间位置 ,然后左右拓展翻转的区间,这样就可以使翻转的复杂度降维;总复杂度 ;
1 | const int N = 2e5 + 5; |
比赛中的话我真的能写出来吗?应该把,应该把(
二维平面的第一象限上有 个点;每次操作,你可以选择两个没有被选中过的点,分别将它们向右或者向上移动一个单位,如果这两个点和原点三点共线,那么这次操作是有效的。问你最多可以执行的有效操作数量,并输出一种可能的操作序列。
看起来很没有办法,但是涉及到简单平面几何,能想到的关键字也就那么多。但是即使这样,我也属实没有考虑到这实际上是一个暴力搜索的题目(
我们将每个点经过两种不同的移动之后得到的位置计算出来;那么,如果两个点可以进行一次有效的操作,就等价于它们变换得到的一个位置到原点的斜率是相等的;一个点可以进行两种不同的移动,就意味这一个点可以关联两种不同的斜率:这样,我们就得到了一张以斜率为点,而以每个可操作节点为边的图。
那么,问题就变成了找到最多的边对,它们之间共享了一个端点;或者说将每一条边分给一个端点,令端点 分到的边数为 ,那么答案就是 ;这是一个非常经典的问题,一种解决思路就是 DFS:对于点 ,如果 是奇数,就把从父亲到自己的边给自己,否则就给父亲;这样就完成了对树边的分配:
那么 DFS 树中的其他边呢?因为这是一个无向图,对于其中的每一个连通块而言,其 DFS 都是一个生成树,也就是说不存在跨越边——剩下的边对于父亲来说是前向边,对于后代来说是后向边。那么对于这些边,为了实现方便,全部都给父亲即可——而且这样也可以显然得到,这种方案只会在总边数为奇数的时候,在根节点处浪费一条边。
很经典,就是利用到父亲的边平衡子树的奇偶性;最多浪费的那条边也就是连通块生成树的根节点,他没有父亲,所以如果真的不能匹配也就只能浪费掉了。
因为斜率是铁分数,所以需要离散化(标号)
1 | const int N = 2e5 + 5; |
实现意外简单(
有 个宝箱,其中第 个包含了 个硬币;有 把钥匙,其中第 把可以卖 个硬币。这些钥匙分别对应了不同种类的锁;如果要在第 个箱子上安装可以用第 把钥匙打开的锁,需要 的成本;如果一个箱子上挂了多把锁,那么只有持有所有的这些钥匙才可以打开宝箱。
现在,Alice 持有这些宝箱和钥匙,并付出一定的成本来为箱子上锁;随后,Bob 会花一些硬币购买钥匙,并打开可以打开的所有宝箱以拿走其中的硬币;如果最后 Bob 的净利润严格大于 0,Bob 胜利;否则 Alice 胜利;现在,要求求出 Alice 必定胜利所需要付出的最少成本。
数据范围: ;;;
只能说看到这个题目,是非常的没有想法了;但是数据又很小,像极了乱搞……那么问题就是怎么乱搞了(
首先,在解决这个问题之前,我们先解决一个经过劣化后的问题—— 如果 Alice 上锁的方式已经确定,那么 Bob 要怎么购买钥匙才能获得最大的收益呢?
为了解决这个劣化后的问题,我们对题目中描述的模型进行建模:将宝箱全部看作连接了源点的节点,且和源点连边容量为 ;将钥匙全部看作链接了汇点的节点,且连边的容量为 ;根据宝箱的上锁情况,从宝箱出发到对应的钥匙连边,边权是 ;这样,我们就将上面的问题变成了一个网络流的问题。
那么,这个网络流的含义是什么呢?首先,我们可以获得的最大的收益是所有箱子的硬币之和,我们所支出的成本最多是所有的钥匙的价格之和;每当上锁,我们就相当于增加一个箱子打开的门槛—— 对于一个具体的箱子,我们打开它的成本是它所使用到的锁的钥匙的价格总和,但是不超过箱子能提供的硬币数量—— 没有人会去做一件亏本的事情。初始情况下,我们可以免费打开所有的箱子,但是因为上锁,所以我们需要付出额外的成本:因此,连边就代表了上锁带来的额外成本——这个箱子的成本需要由它上的锁对应的钥匙分担;综上所述,确定了上锁方案的情况下,Bob 得到的最大收益是:
如果一个箱子的收益完全被需要用来开它的钥匙抵消了,那么就没有开的价值了;但是如果一把钥匙承担了超过它本身价值的收益的话,那么这把钥匙就有买的必要;一种达到这个最大收益的办法是购买所有出边满流的钥匙,但是购买的钥匙数量未必是最少的。
虽然,这种建模方法和常见的裸的网络流并不一样:因为钥匙和箱子的关系是 &
的,因此如果按照代价建模的话,网络流可以恰好地考虑到钥匙开锁的性质和成本。
但是,即使数据范围像本题这样小,上锁方案还是有 种;如果要枚举所有的上锁方案,其时间复杂度是 的;因此,我们回到这个问题本身:注意到,如果 Bob 总是按照最大收益来进行操作的话,那么 Bob 的收益是不可能为负数的——因为 Bob 总是可以选择不购买任何钥匙,不打开任何的箱子,这样最后的总收入是 0;那么带入到上面的网络流模型中,就是所有的宝箱的价值都作为成本流量流入了汇点;换句话说,这个网络流的一个最小割是源点和其他所有节点。
那么考虑上述的网络流中没有任何上锁的连边,我们现在来考虑在这张图中连边;我们用状态 来表示当前的网络流图的连边状态: 代表从源点到宝箱 的连边流出的成本, 表示当前考虑的宝箱, 表示当前考虑的钥匙编号 , 表示从钥匙 到汇点的连边流入的成本;现在,我们考虑所有的 的连边,进行动态规划:
如果使用这条边转移流量,那么需要满足 和 的边都还有足够的流量剩余;更新状态之后,若所有的 都已经满流了,则说明成本已经足够抵消所有的宝箱了,更新答案;同时,按照一个特定的顺序考虑 ,将下一对要考虑的点对更新到状态中。如果状态合法,就更新到 dp 数组中。
简单地说,因为数据范围小,我们枚举所有满足了约束条件的转移方法导致的状态,并计算所有的流空的状态的花费的最小值。因为流量最多为 ,而最多的情况下也只有 条边,在考虑到每条边容量的约束条件,实际上状态数是相当有限的。
一些小细节:我们考虑 的顺序是先改变 再改变 ,这需要体现到映射函数中。
此外,因为考虑使用边运输流量的过程,一定是 流出的递增的过程,所以在映射函数中这一部分的值总是在最高位;
因为每次要将所有的 的状态考虑周全,所以流出流量 在映射函数中是最低位的。
1 | int n, m, a[10], b[10], c[10][10]; |
只能说从网络流建模,到最后的那个 DP 求解,没有一个是现在的我能想出来的()图论,网络流,恐怖如斯——这也不是我近来第一次被网络流题目锤了== 得想点办法才行。
实际上,这个题涉及到的东西远不止这篇题解胡说八道的这么点,与之相关的知识点比想象中还是要多出许多的。
没什么特别想说的,一周不写代码手感属实全无了== 实际上 E 也不算是什么难题虽然我也没有想到极角排序什么的,但是为什么每一次遇到这种题(不管是我自己打 cf 还是在线下训练中遇到了)都做的非常的费劲呢——还是做题思维的问题吧,ACM 之所以我这种人也能勉强打打,就是因为比起 OI 而言它会更加的注重思维水平;所以还是得多想多学(
此外,我的图论属实有些拉跨——这个 F 实际上还有很多关键词相关联:比如什么最大权闭合子图啊,什么霍尔定理啊,什么二分图匹配啊之类的,但是十分恐怖的是我都不会;就算是偏向思维的比赛,也是要建立在你有足够的知识积累的基础上,只能说这样属实不行()得花个专门的时间搞搞图论才行啊 ==
]]>本文的服务器端仅使用 Windows 10 自带的 IIS 控制面板。
一些开始教程之前的准备;
使用 Win + R 运行 optionalfeatures
,或者在搜索菜单中搜索“启用或关闭 Windows 功能”;
找到 Internet Information Services
,将其下的“FTP 服务器”的所有子选项勾选,并勾选“Web 管理工具”下的“IIS 管理控制台”。
打开设置,进入应用 ->
应用与功能,点击“可选功能”;搜索 OpenSSH
,找到并安装“OpenSSH 服务器”和“OpenSSH 客户端”。
现在,你应该可以通过计算机管理,或者搜索菜单找到“Internet Information Services (IIS) 管理器” 了。IIS 管理器就有点像之前的云服务器面板一样,不过是 Windows 自带的就是了(
但是因为 Windows 自身也是有防火墙的,我们要先允许 FTP 服务通过 Windows 的防火墙;在搜索菜单中搜索“允许应用通过 Windows 防火墙”,点更改设置,找到并勾选“FTP 服务器”;然后点击窗口右下角的“允许其他应用”按钮,浏览路径,找到 C:\Windows\System32\svchost.exe
,并添加,确定。
其实在配置远程桌面服务的时候也需要在这里配置这一项目
其实你要我说也说不太清楚,截至今天我总共已经翘了 2/2(100%)节计算机网络的课了;但是一些常识性的东西还是能简单的说说的:
协议类型 | 端口 | 备注 |
---|---|---|
FTP | 21 | Windows 资源管理器唯一支持的 FTP 形式;明文传输,安全性差,但是很方便 |
SFTP | 22 | 使用 sshd 作为守护进程;安全,但是传输效率较为低下 |
FTPS(Explicit) | 21 | 需要一个证书;客户端先发起一个明文的请求,然后再切换到加密连接进行 |
FTPS(Implicit) | 990 | 同样需要证书;全程加密连接,服务端会拒绝掉所有尝试不通过加密的请求 |
实际上 FTP 开放的端口并不止一个——有命令通道和数据通道,21 一般指命令通道;FTP 的连接分为主动模式(Standard)和被动模式(Passive):
一般来说,由于防火墙一般不允许接受外部发起的连接,所以外网的客户端可能无法使用 Passive 模式连接;
FTP 家族比较混乱,一般来说都用 SFTP 了;FTP 协议由于非常的不安全,近年来主流的浏览器都已经放弃了在浏览器中内置 FTP 的支持了——毕竟基础的支持 Windows 资源管理器也提供了。
对于 FTP、SFTP、FTPS 三种服务器的配置方法:
进入 IIS 管理器,在左侧的连接栏中找到“网站”,右键增加 FTP 站点;
在弹出的窗口中指定名称和映射的物理地址,点下一步:
关于 IP 地址,你可以填写你的 IP;如果你不知道填写哪一级别的 IP,你也可以默认“全部为分配”,相当于填入了通配符 *
;如果设置了其他的端口,那么访问时的 IP 地址需要带上端口号;如果提供了虚拟主机名,那么访问时还需要加上虚拟主机名,因为没有必要所以这里统统默认。
因为我们现在还不是配置 FTPS,所以选择“无 SSL”。
接下来是配置权限的页面;如果是作为一个公开的 FTP 服务器,可以勾选匿名身份验证,授权全部用户读写权限(当然,这是不可能的);否则需要进行配置密码:
用户组策略设置只有专业版的 Windows 10 才有;在计算机管理中的本地用户和组菜单中可以新建用户(组),并且进行细致的组策略管理。我们新建一个一般用户,并设置用户名和密码
注意需要取消勾选用户下次登陆时须更改密码,不然会比较麻烦。
然后我们回到 IIS 面板,进入我们刚才创建的服务器的主页;首先进入 FTP 身份验证页面,并且关闭匿名验证:
然后再进入到旁边的 FTP 授权规则页面,增加允许规则,并选择我们刚创建的用户(们):
不出意外,现在已经可以正常在 Windows 资源管理器里输入 ftp://localhost/
来输入密码访问了。
Windows 10 家庭版不支持组策略管理,但是支持创建账户;我们可以直接在设置页面创建一个本地的标准账户,并赋予密码,也可以在这里设置允许规则;唯一的问题就是无法进行精细的权限管理,当然,作为一个实用派的萌新,倒也没有必要搞过于精细的权限管理;真要怕被日用时开不用时关就行了,而且 FTP 一个明晃晃的明文传输,人家要真想抓包搞你你也没有什么办法(
还有一种做法是在服务器主页的右侧栏中的基本设置里(如下)的连接为中设置用户名密码,而在服务器设置里设置允许所有用户:
但是因为我实在是不太理解微软的权限管理,所以这里也就没有尝试,不多废话了。
当然,如果你怕麻烦而且心很大,也可以使用你当前的账户直接连接到 FTP 服务器;此时你的账号名可以通过在 CMD
中运行 echo %username%
获得;如果你当前使用的账户连接到了 Microsoft 账户,那么也可以使用 MicrosoftAccount/your@email.com
作为你的用户名,登陆密码作为你的密码。
FTPS 是 FTP over SSL 的简称,分为显式和隐式两种;很遗憾 XFTP 7 学生版并不支持似乎标准版也不支持,所以如果你想要通过 FTPS 连接你的 FTP 服务器,那么你需要下载合适的客户端:
当然,既然是走 SSL,那么你首先要有一个 SSL 证书;可以自签,也可以绑了域名后申请一个免费的;因为我有在 DNSpod 上托管腾讯云买的域名,所以可以直接方便地申请到免费的证书;下面介绍腾讯云的证书使用方法:
下载 SSL 证书压缩包后,可以看到其中包含了一个 IIS 专用证书的目录;我们进入 IIS 面板,选择计算机名称进入到服务器主页,进入服务器证书页面:
然后再右侧栏中选择导入证书,选择下载的证书文件;然后根据你的 SSL 证书提供商的说明来填入相应的信息。
增加完成后,你就可以在所有的需要使用 SSL 的证书的地方找到你刚增加的 SSL 证书了。当然,如果要使用这种来自运营商颁发的证书,建议在 DNS 处增加一条解析记录,这样访问也更加优雅。
进入你创建的 FTP 服务器的主页,打开 FTP SSL 设置:
进入之后就可以指定 SSL 策略了;如果选择允许 SSL 连接,那么使用 Windows 资源管理器等软件的基本 FTP 的访问将还可以进行(当然,它们依然不安全);如果选择需要 SSL 连接,等于强制 FTPS,此时只能通过支持 FTPS 协议的客户端来访问 FTP 服务器,当然,也相对更加安全。
因为 FTPS 隐式是通过一个固定的端口 990
来确立安全连接的,所以我们需要使得我们的服务器允许来自 990 端口的连接;进入 FTP 服务器主页,在右侧的操作栏中找到“绑定”并进入;增加对 990 端口的监听,并将类型设置为 ftp,就像下面这样:
这样重启服务器后,我们就可以通过支持 FTPS 的客户端,使用隐式的方式访问 FTP 服务器了;当然,即使不做这一步操作,我们也可以通过显式的方式使用 FTPS 协议访问 FTP 服务器。
SFTP 和 FTP 完全不是一个东西;它借助了 sshd
运行,是 SSH 套件中的一个。所以在使用它之前,我们首先要在设置菜单中增加 OpenSSH 服务器功能;
然后,我们可以在管理员权限下的 Powershell 中运行:
1 | net start sshd |
来启动 SSH 的守护进程;现在,我们应该可以在其他电脑上是用 SSH 或者客户端访问它了;使用支持 SFTP 的客户端(比如 Xftp 7)来访问它,可以直接访问到整个电脑的根目录。
当然,也可以像在 Linux 中修改 sshd
的配置文件一样修改;在 Windows 10 中,这个配置文件的绝对路径一般是 C:\ProgramData\ssh\sshd_config
;
比如我们限制 ftpuser
只能访问 D 盘
1 | Match User ftpuser |
可以把上面的内容增加到 sshd_config
的末尾。
将下面这句话增加到配置文件的末尾:
1 | ForceCommand internal-sftp |
当然也可以配合缩进,限制特定用户的 SSH 功能(像上面那样)
配置过程中出现的一些神秘问题的记录
XFTP 竟然不支持 FTPS,这是我万万没有想到的(
博主最开始尝试在 IIS 增加用户允许规则的时候甚至没有发挥作用,但是重新创建一个 FTP 服务器就没有问题了;只能说是十分神秘了(
SFTP 查看的文件列表似乎存在不全的情况,比如下图:
可以稳定地在各种客户端上复现,不知道是什么原因()不然就用 SFTP 算了,谁搁着 FTPS 呢==
哎,不能再折腾了,凑合着能用就要干活了,那这篇文章先到这里;如果有什么错误的话欢迎指正,如果可以解释上面的这些神秘问题的话也欢迎补充()
]]>比赛的那天 KS 酱办生日聚会,所以玩的有点晚——于是为了避免掉分就又开了个小号——结果还真的算是预防成功了((只能说不愧是我==
和上一场一样的出题人,也是熟悉的五个题目;但是这场总感觉比之前那一场要难一些——可能出题人不经意间触及了我较多的知识盲区吧(
右手就行可是我白给了一发;只要取出前面的加到最后一个元素上就行了:
1 | const int N = 100; |
到底是什么样的小天才才能写出 ++ l, -- r
这种代码呢?以为很对称🐎(
考虑到最后只可能剩下两个相同的元素或三个相同的元素——因为更多的元素都可以合并到这两种情况上,所以只需要分别处理即可:
1 | const int N = 2060; |
之前白给成了直接排除一个元素,显然这是不科学的(
提供长度为 的数组 ,你要从中删除一些元素,使得不可以将这个数组拆分成两个部分,使得两个部分的和相等;要求最小化删除元素的数量并输出删除元素的坐标。
首先,显然当 是奇数的时候不用删除任何元素;然后,为偶数情况下可以进行背包 来判断是否可能完成这样的划分;如果可以,那么问题就变成了如何删除元素。
不难想到最多只会删除一个元素,那么问题就是删除什么样的元素;我最开始因为删除最小的元素然后白给了一些罚时,因为这样是不正确的——比如删除 2
,可以通过交换两个 2
和一个 5
来使得再次平衡;那么我们在考虑其他的一定可行的情况,就不难想到删除一个奇数。
如果整个数组都是偶数怎么办?那么我们可以整体右移 位,显然和原数组是等价的;我们可以一直右移,直到我们找到了可以删除的奇数为止;显然,这一定可以找到;
从右移等价,我们可以联想到整个数组除以任何同一个数都是等价的;所以,一个最简单的方法就是首先约去整个数组的 ,然后找到一个奇数删除就行了——因为是等价的,所以这样的删除是合理的;
这个使用 bitset
的可行性背包实现属实颇有雅趣(
1 | const int N = 2050, M = 110; |
于是这个卵题我白给了近十发,,我是真的菜(
给一个长度为 的数组 ,进行 次询问:每次询问关于一个区间 ,将它分成的最少的段数,使得每一段的 都和连乘的乘积相等;求这个段数。
首先,不难意识到 和连乘积相等就是说这一段的 为 。那么问题就转化为了对于任意段 ,将它划分成互质的最小的段数。
那么一种很显然的想法就是对于范围内的所有质数(约 个)分别维护一个列表,包含了包含它为质因数的数字的下标;然后对于一个位置,对于它的每一个质因子在对应的表上二分查找出下一个位置,取最小值作为下一个区间开始的备选位置;但是这样做显然非常的啥b,因为有显而易见地简单优化但是我也显而易见的没有想到:
那么这样,我们就可以维护出一个表 ,表示从 开始可以转移到的最远的备选位置;
但是这样还存在一个问题:如果所有的数字都是 ,那么上面的做法会被卡成 ;因此,为了能够快速的跳转求出区段数,我们可以利用倍增的思想,维护下两个、下四个备选位置;这样,就可以在 的时间内完成转移,并且像二进制拆位那样构造出任何一个数字。
使用类筛的方法完成质因数分解。
1 | const int N = 1e5 + 5; |
即使可以想到正确的维护方法,但是倍增的思想也不能不说是十分的高妙…… 学到许多(
现在有长度为 的排列 ,可以进行对于两个不同的下标两两交换的操作 次;问对于 次操作可以得到的不同的排列数量;
首先,先说明一种贪心地将长度为 的排序 复位的做法——对于排列的最后一个位置 :
可以证明,这样处理完整个序列就可以得到将序列 复位的最小操作次数。
那么,基于这种思想,我们可以构造出一种递推关系——假设 表示了进行 次交换后可以复位的、长度为 的排列(或者说从复位的排列开始,进行 次交换可以产生的排列)的数量:
那么,基于这个动态规划,我们可以有两种不同的做法:
注意到题目中的 非常的小,所以即使进行 次交换,最多只会使得 个位置错位;所以我们每次只需要能选出错位的位置长度,然后对于在这个范围内的长度求 即可;那么,一种很显然的做法就是确定一个允许的错位位置的长度 ,对于这个长度求 ,最后统一考虑——
那么,答案长下面这样吗?
不,当然不对——因为我们的 可能实际上只变动了其中很少一部分的位置——而这样的话就会不可避免的和其他情况重合,导致计数的不准确;所以为了解决这个问题,我们可以考虑在错排问题种采用的解决方法——使用容斥原理包含/排除重复的部分:
所以,我们就可以在 的时间内完成一次 的求解;算法总复杂度是 。
一个很容易注意到的事情就是 可以由 转移过来——因为你可以连续两次进行相同的交换来浪费两次交换机会== 由此,也很容易联想到奇偶分开(
1 | const int N = 500; |
一些说明:很显然 ;
首先,我们需要先观察上面得到的那个递推公式,并考虑更深刻的理解它:
形式化的说,我们可以得到下面的 的表示形式:
那么接下来,对于全部需要的 ,我们考虑从 转移到 :
一个很显然的想法,就是我们从 中选择 个,从 中选出剩下的,组成上面所提到的从集合 中选出的大小为 子集;形式化地说就是将两部分的结果乘起来
前半部分的答案很显然是 ,而后半部分的答案我们没有维护;但是我们可以把它看作从 中选择了 个,然后对于每一个都加上了 ,也就是下面这样:
那么这个式子展开是什么样的呢?因为这个连乘长得非常像二项式展开但是不是,残念(,所以我们可以想象一下它展开后的样子:
上面的式子中,有 ;首先是选出坐标的组合数,然后乘上“二项式系数”。
然后,观察上面的式子的后半部分,我们会惊喜地发现它就是 ,是我们已经维护的东西!
综上所述,我们可以通过下面的转移方程完成从 到 的转移:
上式中的 可以通过 通过下面的多项式乘法转移得到:
当然,基础的从 和 向 的转化仍然有效;因此我们可以转化到任何的 :相当于我们从 出发,然后使用 和 操作构造任何的 ——在构造的过程中完成上面的转移就可以了;一种很显然的思路就是二进制拆位,然后按位构造 :
基本的思路就是每次扩增 ,如果这一位为 1
就再额外进行一次 扩增:
1 | const int N = 500; |
上面的代码中的 30 - __builtin_clz(n)
的含义是: int
类型的 n
的最高位的 1
的所在的位置的低一位的下标;我们使用它作为扩增起点——不使用最高位的原因是我们已经从 出发了,所以不需要对于首位的 进行额外的扩增。
当然,上面的实现中的每次转移的时间复杂度是 的,进行 次转移;如果可以使用各种手段加快单次转移的速度,理论上可以做到 (时间复杂度 )但是我不会,原作者也不会
罚时是真的多,,明明还是可以回到 1700 的场嗯给我打成了防掉分场,只能说非常地不行了(
最近的准度不行啊,还是以后要多加注意才行;补题也要进行的更加迅速才行,不然题目真的补不完力昨天半夜的 Div.1 + Div.2 还没有下落呜呜(呜呜呜
那么,这篇文章着眼于如何推出错排问题的式子,以及它的简化式子展开介绍:
现在,我们假设有一个包含 全部 个元素的排列 ,并且按照 -下标方法定位;最开始时,对于任何 都满足 ;那么我们对它进行操作,将它变成一个重排:
首先从原来的序列中取出一个下标;因为不管选择哪个都是等价的,我们选择 位置,取出
再从剩下来的 个正确的位置中,选择一个位置 ;我们将要把 放入 位置,取出
显然,这一步对于位置 的选择有 种不同的选择。
接下来,我们要将 放回序列中;我们有两种选择:
将 放入位置 ;这样相当于交换了 和 的位,使得了这两个位置符合了错排的考虑
现在还剩下 个位置,它们都是有序的;可以被作为一个子问题递归地处理
将 放入位置 ,也就是不放入位置 ;这种情况下,我们可以这样考虑:
综上所述,第一种选择是作为 的子问题,第二种选择 的子问题递归。
那么,由于乘法法则和加法法则;令 表示长度为 的重排排列的数量,那么可得到:
且特殊地存在 和 ;这就是我们得到的第一个递推式子;
这一步的化简网上一般有两种做法;作者选择了她喜欢的一种详细介绍:
对于上述得到的重排公式,我们在等式的两侧同时减去 ,可得:
所以,可以得到化简后的递推公式:,特别地 ;
另一种的做法假设 ,那么 ,;
对于 的场合,将第一个递推公式 根据定义拆分:
其实这种推导和上面的是完全等价的——不如说看起来几乎完全没有怎么化简;只是因为这种推导方法在得到通项公式的时候更加的自然再推导一步就得到通项公式了,所以删了(。
只有递推公式有的时候是不够的,所以我们还需要依据上面求出的递推公式求出通项公式;这里作者提供了三种不同的推导方法:
对于形如 的递推方程,经过暴力展开,可以得到下面的公式:
对于我们求得的递推公式 ,不难通过观察得出三个部分是 、、;带入上面的暴力公式中,可以求出:
那么就得到了错排数量的通项公式。
在板块一提到的第二种方法化简得到的:;我们将代入多个 并将得到的式子的左侧和右侧全部相加,可以得到:
又因为 ,所以 ,和上面的化简结果一样。
对于长度为 的排列,一共有 种不同的排列;我们要求的重排排列是它的子集;因此我们考虑,如果我们将所有不是重排排列的情况去除,那么剩下的就全部是重排排列了!
假定排列的长度为 ,我们令 为满足 的排列种数,那么显然可以得到:
因此,每个元素都不在对应位置的时间就是对于 的 :
显然,这也和之前的推导结果是一致的;
那么我们可以怎么理解式子中的加加减减呢?我们首先删除以每一个 不满足的位置,但是这样对于排列中出现了两个不满足的位置的情况额外减了一次,所以要加回来——首先组合选出两个位置,然后其他的位置全排列;但是这样又会导致有三个不满足的位置的情况多加了…… 以此类推直到所有的位置都不正确的唯一情况。
百度百科的证法实在是太过玄妙,,如果有看懂了的欢迎讲给博主听(
但是在算法竞赛中使用这样的超级复杂的多项式通项公式约等于没有,甚至效率不如直接递推;所以我们需要使用起来更加方便的通项公式:
首先我们考虑 的幂级数展开(麦克劳林公式/泰勒展开):
泰勒展开后面还带一个余项,不过这我就真的不会了高数早就还给微积分老师力(
对于 ,我们可以得到如下的泰勒展开式:
所以,我们可以认为:,或者 ;又因为余项 在 取 时取到上界 ,因此可以得到:
在我们的通项公式的可行域 中,显然满足 ;因此我们可以认为 和最接近 的整数相同;所以最后我们可以将化简后的通项公式写成下面这样:
向下取整在代码中实现也非常方便,就达到化简通项公式的目的。
这种裸题应该还是挺多的;所以我就随便挂一个经典原型了:
cses.fi
1717有 个小盆友,每个小盆友都买了一个圣诞礼物与其他的小盆友交换;要求每个小盆友都要得到来自其他小盆友的礼物;问一共有多少种交换方式。
1 | const int mod = 1e9 + 7; |
因为数据范围很小,所以只需要使用递推公式就可以通过此题了。
虽然推的时候还是很痛苦的,但是写博文的时候又觉得言之无物,,看来还是昨天晚上睡少了(
如果有机会的话再完善一下关于错排生成算法的东西吧……