移动端算法优化是个很庞大的话题。从计算机体系到指令,涉及到非常广而深的东西。本文尝试以常见的算法为例,阐述算法在单线程场景下的加速与优化,多线程是最后的收尾,没啥可说的。而至于具体的场景,如金字塔、滤波、降噪等,优化的思路都是相同的:减少 IO,一次 IO 完成尽可能多的计算。
本文会使用 Neon, OpenCL
来优化算法,如果有可能也会引入 DSP
。本文持续更新,整理算法优化相关的经验。额外的,确保打开了 O3
编译选项,打开 release
模式等,否则会影响算法的执行时间。
注:本文不考虑数学角度的优化,如修改计算公式得到相同结果什么的。实现的浮点矩阵计算为:
简单起见,$A$ 的维度为 $512\times 128$,矩阵 $B$ 的维度为 $128 \times 256$。在高通骁龙某芯片上,目前的加速结果如下:
版本 | 时间 |
---|---|
常规矩阵乘法 | 59.84ms |
Neon 加速版本 1 | 12.90 ms |
Neon 加速版本 2 | 3.85ms |
Cache 友好的矩阵乘法 | 2.52ms |
Neon 加速版本 3 | 2.77ms |
Neon 加速版本 4 | 2.01ms |
Neon 加速版本 5 | 1.09ms |
为什么没 OpenCL?因为还没来得及写,仿佛欠着好多博客。
以线性代数中的矩阵乘法为例,目标矩阵的第 $i, j$ 个元素是矩阵 $A$ 的第 $i$ 行和矩阵 $B$ 的第 $j$ 列逐元素相乘相加的结果。根据这一原理写出最直观的代码,耗时 59.84ms:
1 | void sgemm_c(float *C, float *A, float *B, float *bias, int d0, int d1, int d2) |
我们知道矩阵在计算机中是行朱序存储的,即访问矩阵 $B[i, j]$ 时,会将 $B[i, j+1], B[i, j+2],…$ 等元素也一同取到内存的 cache
中。当需要 $B[i, j+1]$ 时就从 cache
中读取而不是去内存读取,这样会节省很多时间。
所以上述代码的性能瓶颈在于:
1 | for (m = 0; m < d1; m++) { |
由于最内层的循环中 m
逐渐增加,矩阵 $B$ 的寻址方式为跳行寻址。在我们看不见的地方,cache
缓存的数据无法使用,每次读取 $B$ 矩阵的元素时还需要刷新 cache
,这就导致这份代码很耗时。
1 | void sgemm_neon1(float *C, float *A, float *B, float *bias, int d0, int d1, int d2) |
1 | void sgemm_neon2(float *C, float *A, float *B, float *bias, int d0, int d1, int d2) |
1 | void rsgemm_c(float *C, float *A, float *B, float *bias, int d0, int d1, int d2) |
1 | void rsgemm_neon1(float *C, float *A, float *B, float *bias, int d0, int d1, int d2) |
1 | void rsgemm_neon2(float *C, float *A, float *B, float *bias, int d0, int d1, int d2) |
1 | void rsgemm_neon3(float *C, float *A, float *B, float *bias, int d0, int d1, int d2) |
之前对 C
语言中宏定义的认知十分简单,包括但不限于停留在以下浅薄的层面:
1 |
上述代码完全是大学课本中的用法。但当我看到实际项目中宏的用法后完全是一头雾水,所以自己也要写出那种高逼格让别人看不太懂的代码。宏远远比我想象的要强大,所以本文为每个宏技巧都配备了一个实用场景。
__VA_ARGS__
,实现一个简单的日志函数1 |
|
上述宏 str
通过单井号的形式实现了字符串化操作符,将传入的参数字符串化。
C 语言有一些预定义的宏,比如 __LINE__
表示当前行号,__FILE__
表示当前的文件名。基于这一基础,我们实现一个简单的测试程序。在测试程序时,打印测试用例、文件名、行号、以及是否通过测试。
1 |
|
#val
会打印测试样例__FILE_LINE__
会打印当前的文件名和行号输出如下:
1 | demo.cpp:30::calling 1 == test_func() |
当时我看到这一用法也比较疑惑,但 do-while(0)
的用法还是比较常见的。多用于在一个宏定义中出现多条语句的场景中,那我们来分析一下为什么要这么用。如果我们这样定义:
1 |
在以下的使用场景中:
1 | if (cond) |
宏展开后,会变成:
1 | if (cond) |
所以不管 cond
是真是假,stmt2
语句都会执行。而我们自己的意图肯定是,只有 cond
为真的时候,stmt1
和 stmt2
才会执行。那我们给宏加上花括号试一试:
1 |
但是在下面这种情况下,还是会存在一些错误:
1 | if (cond) |
这样宏展开的结果为:
1 | if (cond) { |
直接导致编译错误,而出错的原因是 else
前面多一个分号。当然也可以在使用 SS
的地方后面不加分号,但是在 C 语言中通常我们习惯性的会在语句后面加一个分号。鉴于上面的这些原因,就有人想出了 do-while(0)
式的用法:
1 |
1 |
|
上面代码的意思是,将 a_
和传入的 tag
连接在一起,意思是:int a_MAX = 77;
的意思。上述代码中完全没有直接出现 a_MAX
这个字符串,但我们依然可以使用。
这样做的一点点好处是:比如现在有 100 个模块分散在项目的各个角落,需要给各个模块计时统计性能。那么每次都定义起始时间、结束时间,并且计算执行时间,这些操作都是重复的。为了精简重复的操作,我们可以使用这个宏技巧来实现。如下所示的代码,我们把宏放到头文件,用户在引用头文件后,只需要两行代码就可以快速完成对模块的计时功能。
1 |
|
输出如下:
1 | loop_func_20 cost 202.44ms |
__VA_ARGS__
是一个预处理器宏,用于表示可变参数列表。它通常用于定义可变参数的宏,例如 printf
函数。在宏定义中,__VA_ARGS__
表示可变参数列表部分,可以在宏展开时将其替换为实际的参数列表。官方定义较为玄幻,直接看代码吧:
1 |
|
给上述代码加一些辅助信息,就可以实现一个日志函数:
1 |
|
对于
1 | LOG("BASE", "Nothing"); |
宏展开为:
1 | printf("[%s] [%s %s %d] " "Nothing", "Base", "demo.cpp", "main", 7); |
注意,Nothing
这个信息是在 format
中,因此第一个 %s
对应的是 tag
,所以最终输出为:
1 | [BASE] [test.cpp main 8] Nothing |
同理,第二个宏展开后的输出为:
1 | [BASE] [test.cpp main 7] ? info diff >= 2 : 0.1000 2 |
##__VA_ARGS__
而不是 __VA_ARGS__
,这是因为 ##__VA_ARGS__
用于在可变参数列表为空时删除前面的逗号。在 C 语言中,如果可变参数列表为空,则在逗号之后没有参数,这会导致编译错误。通过宏定义的方式,根据指令执行不同的函数。比如输入的指令是 CMD_LED_ON
,执行的函数是 led_on
;输入的指令是 CMD_LED_OFF
,执行的函数是 led_off
。首先定义这两个函数:
1 | static void led_on(void* p) |
将这两个指令 CMD_LED_ON
和 CMD_LED_OFF
定义到一个枚举变量中,不过是以宏的形式:
1 |
|
#define X_MACROS(a, b) a
表示取出 (a, b)
中的第一个元素 a
,则宏展开后的代码为:
1 | typedef enum |
#define X_MACROS(a, b) b,
表示取出宏的第二个元素。使用同样的方法,在定义一个函数数组:
1 | typedef void (*func)(void* p); |
此时,func_table[CMD_LED_ON]
指向了 led_on
函数,func_table[CMD_LED_OFF]
指向了 led_off
函数,就实现了简单的根据不同的输入指令执行不同的函数。完成代码如下:
1 |
|
整体的开发感受是:缺乏一个合理的、完整的软件开发流程或规范。
合理是指:大多需求都是由领导拍脑门、飞书、现场沟通传达。尤其在面临这种前路未知、需求多变的任务时,由于背景知识的缺乏, 沟通会更加吃力。最大的缺点是难以记录,不利于软件的维护、更新等。需要加什么功能,改什么功能,为什么这么做,无从查起。
完整是指:什么时候开会和立项,什么时候讨论,怎么样算完成,软件如何发布,如何维护,这些东西没有任何规范。一个软件的生命周期,从需求分析到维护,这些都没有。整体感受和学生时代的大作业没啥区别。
沟通效率很低。
debug
,到后面会发现之前讨论的代码很可能无法实现,或者说并不是最优的实现方式。代码写到那里,自然而然的会发现更好、更便捷的实现方法,回过头来发现前期的讨论除了浪费时间和耽误进度外,没有任何价值。应用场景,用户需求没有任何调研。
delay
,自己很着急,老板很失望。我想写小而美的软件,后面慢慢添加功能;领导希望一次性支持全部功能,这仿佛真的很难实现。比如今天 AI 组又提了一个新需求,超出了我们最开始的规划,真的很难一次性实现全部需求。临时添加功能过于繁琐。
unknown
函数调用、想取消 unknown
的函数调用、想随便生成一个表看看界面什么样子。这些至少还是能应付的,改几行代码去应付即可,只不过累一些。而这些繁琐的临时需求,会发现写完之后不在需要,只会一点一点的消耗耐心,浪费宝贵的积极性。需求不明确
如果某天我当了领导,我大概率会说:先调研,有无现有的高性能实现方案,是写异步函数还是同步函数。然后写技术方案,和我沟通后我确定做的方向与内容,细节你们决定。
bug
,立刻发布提问和发布暂定使用 gitlab
,将软件管理起来。第一次管理软件的维护和发布,处于探索阶段,还需要学习。功能实现或紧急 bug
修复后,关闭对应的 issue
。
写在前面。希望你不会有快速搭建 UI 界面为他人服务这种迫切的需求。虽然这是我的博客,但是我并不希望你搜到他。对于完全未知的领域,快速搭建、快速学习、不会就去学、不会就查、速成,通过这种方式写出来的代码一定是不好的,心累的,事倍功半的,也一定存在多多少少的 bug
和无法实现的逻辑。
但也有一个好消息,如果你完全不会前端后端,只会 Python
,看了本文也能搭建完成的前后端服务,但距离入门的全栈工程师还差很远。
在开发初期,我真的以为是弄一些简单的图表就结束,所以没放在心上。但是越往后项目越大,我的 js
和 html
水平实在驾驭不了,工作时也不会给我足够的时间让我从头学这些东西。每天晚上都在给之前的同学打电话询问:这种交互逻辑该怎么实现。在她帮我写了整体架构后,我便在架构上修修改改,查 api
,整体是能满足需求的。
但是后续,项目又变大了,要求这个,要求那个,要求各种各样的 UI
界面和交互。0 前端基础的我实在应付不了,麻烦同学也不是长久之计,于是开始使用 amis
搭建前端界面。
以上内容摘自百度 amis
的官方文档:
UI
组件库,必须懂 npm
、webpack
、react/vue
,必须熟悉 ES6
语法,最好还了解状态管理,比如 Redux
,如果没接触过函数式编程,入门都很费劲。而入门之后会发现它还有巨大的生态,相关的库有 2347 个,很多功能相似,挑选成本高。然而前端技术的发展不会停滞,等学完这些后可能会发现大家都用 Hooks
了、某个打包工具取代 Webpack
了……amis
只需要几百行 JSON
配置,不需要了解 React/Vue
、Webpack
,甚至不需要了解 JavaScript
,即便没学过 amis
也能猜到大部分配置的作用,只需要简单配置就能完成所有页面开发。amis
的可视化编辑器,快速完成页面的开发。对于大部分常用页面,应该使用最简单的方法来实现,甚至不需要学习前端框架和工具。amis
在百度内部得到了广泛使用,在 6 年多的时间里创建了 5 万页面,从内容审核到机器管理,从数据分析到模型训练,amis
满足了各种各样的页面需求。下载链接中的 sdk.tar.gz
,解压放到本地文件夹。目录结构:
1 | sdk/ |
index.html
中的内容,重点是 14,15,33 行中的 sdk
路径,需要正确的指定。index.html
中的内容:
1 |
|
用浏览器打开 index.html
,就能看到一个简单的页面。当然,也可以打开百度提供的前端编辑器,以拖拉拽的形式完成前端界面的开发即可,类似 qtdesigner
或者 C#
开发 .NET FrameWork
的操作。
友情提示:和任何 UI
开发一样,建议为每个组件提供 flex
布局或者容器,后期容易调整样式,开发出来的 UI
界面也更好看。开发完成之后,点击这个按钮获取 json
文件:
待补充图片
然后拷贝到 index.html
中的 let amisJSON =
字段,就完成了 UI
界面的开发。注意:这里只是完成了 UI
界面开发,并没有和后台的数据相关联,并没有捕捉用户的动作,完成交互和响应需要单独写代码。需要在下图的位置添加事件:
待补充图片
如果你有幸搞过 Qt
或者 .NET FrameWork
的开发,那么一定对这个东西不陌生。熟练使用事件可以让界面的响应更加流畅。下面开始介绍事件的使用,并和后端相关联。
说实话,入职 3 个月培训结束后,一直在被安排干前后端开发的活,为他人提供一些网站服务。然而实际是我是一个算法工程师,每天到工位都感觉自己像个傻逼。
]]>没想到有一天写 python
的时候也会想着如何去节省内存。平时写 python
的时候根本不会关注这些,变量什么的直接创建和使用就完了,也不用考虑内存的释放,反正有垃圾回收机制。只不过这次数据量过大,debug
的时候发现内存一直在申请,导致系统彻底的卡死。
可能也是从事算法的优化工作养成了职业病,每次写代码的时候都会想,这些代码消耗的时间怎么样,占用的空间怎么样,数据结构是否可以继续优化,这些逻辑有没有更优雅的写法。
注:本文程序中使用 psutil
库来监测进程使用的内存大小,需要 pip install psutil
一下。
需要解析一个很大的日志文件,日志文件中含有一些无用的信息,像下面这样:
1 | 有用信息1 |
解析文件的时候,需要从文件中解析并提取出有用的信息,存入一个对象中,完成后续的处理。
但是呢,对于某些特殊的任务和需求,发现文件只解析一次是不行的,也就是需要对文件进行二次解析。
所以为了避免重复的解析文件,在第一次文件解析完毕后,直接把有用的核心信息序列化出去,这样二次解析的话就不用重新读取源文件在解析,直接读取序列化后的核心数据就好了。
最开始的方案是使用一个 list
持续追加解析得到的核心数据,文件解析完毕后把这个很大的 list
序列化出去。监测到进程占用的内存大小为:700MB。
1 | import random |
而如果使用序列化追加的方式,仅用 15MB,耗时增加 2s,毕竟每次序列化的时候都需要打开文件并在末尾追加内容:
1 | with open("data.pkl", "ab") as f: |
这里可以设置一个 buffer
进行优化,buffer
达到一定大小后在统一序列化出去。
1 | class SeriesModel: |
在二次解析的时候,需要把序列化的数据 load
进来。如果加载序列化的文件并且直接处理数据,同样需要使用 700MB 的内存。这种一次性创建所有元素的行为是没有必要的。
1 | with open("data.pkl", "rb") as f: |
可以使用惰性计算来解决这一问题,只有在真正需要这个变量的时候才去创建,而不是一开始就创建所有的变量。考虑到生成器表达式的局限性,我们直接使用 yield
关键字创建一个生成器函数。
yield
语句类似 return
会返回一个值,但它会记住这个返回的位置,下次迭代的时候就从这个位置继续执行,返回下一个元素。这样就消耗内存 15MB。
1 | def read(file): |
任何一个生成器都会定义一个名为 __next__
的方法,这个方法要在最后一个元素之后需抛出 StopIteration
异常。next()
函数的本质就是调用对象的 __next__()
。这个方法要么返回迭代的下一项,要么引起结束迭代的异常 StopIteration
,下面的示例揭示了生成器的本质。
1 | class FibGenerator(): |
示例中如果没有定义 __iter__()
方法则只能使用 next()
函数进行迭代,当它定义后,就可以使用 for
和 in
语句访问了,同时定义了这两种方法的对象称为迭代器。生成器表达式和生成器函数产生生成器时,会自动生成名为 __iter__
和 __next__
的方法,所以生成器也是一种迭代器。
https://pythonhowto.readthedocs.io/zh-cn/latest/iterator.html
]]>职场新人兼新手程序员斗胆开了新坑「如何写出更好的程序」,所见所得都是来自实际写代码时自己的思考,且已脱敏。这一系列不包含任何复杂的技术,也不包含任何难懂的代码。只是将核心问题暴露出来,针对这些场景,如何写出可维护性更高、更简洁优雅的代码。
目前仅包括 python
装饰器的使用,等某天遇到其他技术也可以减少代码的修改时,会追加到本文。
一开始写代码的时候,都在想着要尽可能的支持全部功能,要获取各种信息并反馈给用户。于是我写了一大堆代码,创建了各种类、各种数据结构,以及实现了各种方法。
1 | class A: |
为了高效的获取信息,一些数据可以复用,一些逻辑可以跳过,这样写出来的代码也会错综复杂:
1 | def main(): |
某天忽然遇到一个新需求:需要增加一个轻量版的代码,只得到 3 个核心信息就好了,其他信息直接忽略掉。这时我回首我的代码发现:为了得到各种信息,之前的代码十分庞大,有很多类,也有很多方法,复杂的逻辑修改起来并不是件很容易的事。
如果在代码中手动添加 lite
这一轻量化参数,遇到不需要执行的代码就根据 lite
写 if else
分支给代码加岔路口,代码结构会十分繁杂。比如有 lite
选项时,我们需要创建 A
这个类,根据临时结果判断是否需要执行 b.func4()
,那么上述代码修改为:
1 | def main(): |
对于 1000 多行更加复杂的代码,手动添加 lite
分支并修改逻辑,这是很累的工作,写出来的代码也不好看,通用性也随之变差。
此时我们可以使用装饰器来完成这一工作,如果不知道装饰器是什么东西可以参考我之前的文章。在装饰器中首先传入 self
参数,如果检测到类的 lite
属性为 true
,直接跳过这一函数不执行。此时我们只需要打开需要改动的类,增加 lite
属性。
如果确定这个方法可以不执行,给方法增加装饰器即可。而对于 main
函数中的代码,是不需要任何修改的,也不需要增加大量的 if else
分支,减少代码结构的修改和破坏。逻辑处理部分的代码如下所示,相比坏代码部分精简了很多,且 a.func1
和 a.func3
都是不会执行的。
1 | def use_lite(func): |
补充:@use_lite(self.lite)
是会报错的,因为装饰器是外部方法,并不是类的成员,也就无法捕捉类对象。
职场新人兼新手程序员斗胆开了新坑「如何写出更好的程序」,所见所得都是来自实际写代码时自己的思考,且已脱敏。这一系列不包含任何复杂的技术,也不包含任何难懂的代码。只是将核心问题暴露出来,针对这些场景,如何写出可维护性更高、更简洁优雅的代码。
以 python
为例,本文的主要内容包括:如何使用配置文件,以及如何减少代码中的硬编码,引申到了代码的组织架构和可维护性上。
假设只有在 main.py
中需要读取配置文件,将配置文件的部分变量以传参的形式交给其他函数使用,这是最简单的场景。举个简单的例子,如果是生产环境,那么 env=debug
;如果是开发环境,那么 env=release
,当然这是从配置文件里读取得到的。考虑复杂一些的情况,如果是用户 DIY 使用,可能需要的变量并不在配置文件中。
对于这一场景,建议将配置文件写到 config.py
中,并且用一个类进行封装,变量就是类的成员。当需要根据生产或开发环境执行不同的代码时,只需要在类内进行判断即可。当用户需要增加其他变量时,由用户继承这一个类并添加自己的变量和方法就好。
1 | class Data: |
如果此时有几十个代码文件都需要读取配置文件,获取其中的变量并执行对应的代码,总不能每个文件都创建一个类对象并初始化吧。你说参数传递?如果函数的传参很困难又该怎么办呢?具体而言,当开发后端的时候,main.py
读取配置文件并得到了 env=debug
,此时打开了网页,点击一些按钮完成一些交互,则 web
端会通过 js
发起了一个 post
请求,告诉你需要执行某些代码,这个请求被 handler.py
拦截到。
此时存在一个问题:handler.py
中的 get
方法拦截到 web
端请求,并不是 main.py
直接将请求发送到 handler.py
。所以此时不能直接传递参数,handler.py
也并不知道 env=debug
,所以可能不知道执行哪些代码。再去重新实例化一个类?几十个代码文件都去实例化同一个类,未免浪费空间。
简单的参数可以加到 post
请求的 url
里,但是当参数高达十几个时,传参和接收参数这会很麻烦。何况配置文件就在那里,handler.py
直接获取会方便很多。这个时候建议将配置文件写到 config.py
中,但不是以类的形式,而是直接写入变量并赋值,如 ENV="DEBUG"
。当任何文件需要读取这一变量时,直接 import config; config.ENV
便可获取。有点类似 C
语言中的 #define
。
还有一些通过读取 yaml
,json
等配置文件来生成变量的,但是这会不可避免的增加代码中的硬编码,而且只能获取变量。根据变量去判断执行哪些方法需要单独实现,所以没有考虑使用。具体而言:
对于情景一中的代码,用类实现配置文件的话可以直接调用类内的 __setup()
方法。如果是 yaml
文件,从文件加载到 env1, env2
后,需要单独去写情景一例子中的 __setup()
方法,不如封装到类内方便。
对于情景二,如果几十个代码文件都去执行 import yaml; yaml.load()
来获取配置文件中的变量,这又会造成大量的文件 IO
,没有意义。这也是我不考虑使用 yaml,json
作为配置文件的原因。
在有了配置文件后,可以有效减少代码中的硬编码,增强代码的可维护性。比如创建了一个字典:
1 | data["name"] = ... |
但是此时后台的接口忽然发生了变化,children
这个名字忽然改成了 subfunc
,后台解析只认 data["subfunc"]
这个字段,上面的写法需要去所有代码文件里一个个的搜索 "children"
并替换为 "subfunc"
,显然是很累又不得不干的活。这个时候可以使用配置文件:
1 | config.py |
如果再遇到 children
名字改成了 subfunc
,只需要在 config.py
里修改 CHILDREN
的取值就可以了,只需要修改一次,比上面的实现优雅一些。
另一个硬编码重灾区是函数的返回值,众所周知 python
函数是可以有多个返回值的,对于暂时不需要的返回值可以用下划线忽略掉。
1 | def func(): |
其实上面获取函数返回值的形式更像列表的切片:
1 | def func(): |
可以看到,如果要调用 func
函数,就必须牢记返回值的顺序,当代码文件很多时并不友好,也不优雅。当需要增加或减少返回值的数量时,切片访问函数返回值的形式也很难处理。比如当不需要返回 name
字段时,或者需要增加一个 param
参数,下标都需要修改。增加返回值时, 别说把这个返回值放到所有函数返回值的最后,这只是为了代码能运行起来做的妥协,没意思。以上情况对于调用 func
的函数而言都需要一个个手动修改,简直是一场灾难。
这个时候建议使用类对象或者字典,道理是一样的:
1 | def func(): |
这样,就在也不需要记住返回值的顺序,也不必担心函数增加或减少返回值,甚至不用关注返回值的顺序。都可以直接通过字典的 key
访问。你说 "name", "info"
这样的硬编码不好?可以用前面讲述的配置文件避免掉它呀。
C
这种语言并不支持函数返回多个变量,需要返回多个变量时都是使用结构体来完成,这种想法值得借鉴。对于 python
语言,字典也好,类对象也罢(对象的话就是通过成员访问),取决于具体的适用场景,但是都可以避免通过切片这样的硬编码方式去获取函数的返回值。
对于一个函数,接受原生的数据 raw_data
完成解析,并返回各种信息数据:
1 | def func(raw_data): |
但是其他函数使用返回值时,info1到info6这些信息并不是全部都需要使用。有时候仅仅需要使用 info1
和 info4
,很烂的写法有两种:
1 | 1. |
上述写法,当 func
函数发生变化,如:增加其他返回值、删除无用的返回值时,对于代码维护而言都是一场灾难。千万不要假设需求不会变化,也不要假设针对接口编程时接口始终不变,永远不知道会面临什么新的鬼需求和变动。就算是针对接口编程,每个函数的返回值是什么,返回值的顺序都需要记住,是一种很累的事情。
除了上文讲述的使用字典或者类之外,还有一种其他方法:
1 | class Info: |
只需要创建一个对象,在 get_item
这个函数的参数中指定自己想要获取的参数和顺序即可。即使函数 func
的返回值发生了顺序、数量等方面的变化,也只需要修改一下 __idx
成员即可。
仿佛不如字典简单?确切来说,这种方法有自己的适用场景:当 A
函数获取 info.get_item
信息后需要进行 postA
的后处理,当 B
函数获取 info.get_item
信息后需要进行 postB
的后处理。这样,就可以把 postA
和 postB
放入到 class Info
中,将分散到各地的相同逻辑的代码整合到一起。至于 "info1"
和 "info2"
这种硬编码,也可以用前面讲的东西规避掉。
需要注意的是,这种实现是比较耗时的。如果这个方法到处被调用,会增加程序的执行时间。耗时这一点是通过 py-spy + speedscope
这两个工具发现的,推荐一下这两个工具,用来观察 python
代码中的性能瓶颈。
文件、文件夹都要做好各司其职,不要怕麻烦,写好 __init__.py
,不要把很多文件胡乱的扔到单个文件夹里随意的调用,甚至没有文件夹。时间长了或者当别人用的时候,真的很乱。这次任务我实现了经典的 MVC
模式。
model
就是数据解析,存储和维护一些数据结构,如果想要的数据不能直接获取,也可以在 model
里增加一些获取数据的接口。建议将 model
封装为一个类,在一个方法里读取文件,解析得到数据结构,并放到类成员中,方便接口调用获取数据,也避免重复读文件和数据传来传去带来的拷贝开销。交由一个对象去维护数据,由对象的接口去操作数据。而不是将数据读取放到全局变量,任由各个代码、各个函数随意操作。view
是数据的展示,以什么形式和结构展示给用户,显示界面、写出文件或命令行输出等形式;control
是交互的控制,用于捕捉用户请求,按照请求访问 model
的接口并获得想要的数据,再调用 view
接口反馈给用户。当需要获取很多种类型的数据时,开发重点在 model
部分,因为 control
只是调用获取数据的接口,view
只是展示数据。当需要 A
类型的数据时,control
调用 model
的 getA()
方法即可,当需要 B
类型的数据时,调用 model
的 getB()
方法。
重点就是这两个方法去如何实现,如何设计高效的数据结构去维护数据,来减少数据的拷贝和优化获取数据的效率。总不能 getA()
的时候重新读文件,getB()
的时候再去读文件,对吧。这就需要在 model
部分下工夫,比如这次就用到了数据结构中经典的 dfs
+树的后根法快速解析了数据。leetcode 没白刷了属于是
额外的,在开发 model
时也有其他的收获:写代码尽可能将各个模块独立封装,写出高内聚,低耦合的传说级代码。虽然当函数很多时会很看着有些乱,怎么到处是函数?但是也有重要的优点:代码和数据重用方便。比如要增加一个新功能,只需要写一点函数,其他函数也许已经实现了,我们直接调用就好,而且不易出错。
如果写一个大函数完成一个功能 A
,在写另外一个大函数完成功能 B
,这两个大函数操作的变量会有重叠,也会有一些重复的逻辑。当其中的逻辑过于复杂时,难免出错。十分建议将功能剥离开来。
这种低耦合+配置文件的形式也可以灵活的解决一些暂时不确定的场景。领导告诉你说:暂时有 A,B,C,D,E
这五种类型,需要分类处理,后面可能会有改动。你兴冲冲的把这些类型作为字典的 key
完成了分类处理。
某天领导又说,把 A,B,C
归类为类型 1,把 D,E
归类为类型 2,根据不同的类型创建不同的文件夹,但是后面可能还会变动。不到半小时,又收到通知说把 D
归为类型 1,A
的名字改为 Afunc
,删除类型 2,并增加 F,G,H
为类型 3。既要修改类型,又要映射关系,去大段的代码函数里修改这些内容真的很累的,也很容易出错。这个时候可以在配置文件里写一个映射函数,每次修改这个小函数并调用就可以了。
1 | def map(name): |
总结,不要假设需求是不变的,这样写出来的代码很烂;需求发生改变时,代码修改难度也很大。
程序员最好杜绝以上想法,不然写代码一时爽,改代码火葬场。场景会发生变化,需求永远是在变化。异常情况做好处理,减少代码的硬编码,降低代码功能的耦合度,针对接口编程,学过的设计模式也都可以用起来。避免需求发生变化时大量的修改代码,尽可能通过增加新接口和新函数来适应新的需求。
]]>2023.7.10 入职距今已经两个月零 3 天了,培训课程十分紧张也没来得及做一些技术的思考和整理。主要是下班回家后只想躺着玩手机,周末持续性出去撒欢。 但只学习不思考和整理是程序员的忌讳。培训课程结束后,会对这两个月的培训时间进行思考,同时对未来该怎么更好的工作也进行一个思考,甚至包括如何更好的休息锻炼来保持充沛的精力。
回到正文,git
是程序员写框架和交流代码时的必要工具,而过于贫瘠的实操经验导致我真的不会这玩意。尤其是多人协作 pull, merge
或者 reset
时,时常把代码搞的乱七八糟。所以在这里记录 git
的踩坑记录。
git
操作时很大程度受限于实际的情景,本地基于什么分支进行了什么修改,是否暂存,是否提交,是否有冲突等等等等。出问题后去网上搜索时,网上的例子和本地的例子不一定很符合,或者说只有一半符合。往往不知道该执行哪些命令,是否会把文件弄的很乱无法撤回。
这个时候建议把实际情景描述一下,去问问 GPT
,以我的使用经验,得到的回答 99.9% 都是可用的。
git
开发时,A 分支的代码泄漏到了 B 分支 ?当时想实现 master
分支只有 README.md, .gitignore, 3rdparty
等公共文件。
dev1
分支,并在 dev1
文件夹里面写代码dev2
分支,并在 dev2
文件夹里面写代码这样 dev1
和 dev2
分支的代码位于不同文件夹,互不干扰。最后全部合并到 master
分支的时候,也不会产生冲突。
在实现期间出现了一个漏洞,当完成 dev1
任务的代码后,直接在 dev1
分支下 git checkout -b dev2
,这样就会发生:dev2
分支下有 dev1
的代码,不是很优雅。
当时培训课程的进度比较紧张,也没有刻意去关注这个问题。只是在 dev2
分支下手动删除了 dev1
文件夹的代码,这样在 git status
的时候会看到很多 delete
信息,且会随着 dev2
分支的提交而提交到 gitlab
中,merge
时会看到很多无用的删除文件信息。
随着课程的陆续学习,框架规模越来越大,代码文件也越来越复杂。由于自己的 git
实操很少,担心 git
误操作后导致分支或文件过于混乱。又回过头来重新看这一问题,在本地进行一些简单的实验后发现了正确做法。
在完成 dev1
分支的代码并提交后,应该 git checkout master
,在 master
分支下新建 dev2
分支,这样才能实现 dev2
分支不含 dev1
的代码,保证提交代码时的信息足够干净。
起因:需求是将本地 local
分支提交到 develop
分支。我理解成了将本地的 local
分支提交到 develop
分支,并向 master
提交 PR
。于是执行了:
1 | git push -u origin local:develop |
这样就导致了代码污染。因为可能有其他人基于 develop
分支开发代码,而我的 local
代码直接覆盖了远程的 develop
代码。
develop
代码时,会获取到我的 local
代码,但是我的 local
代码没有经过检查和测试,负责模块整合的人也没有处理我这个模块可能存在的异常。所以很可能在运行期间存在错误。1 | git push -u origin local |
这样远程仓库中就会有一个 local
分支,提交 PR
时将 local
分支提交到 develop
分支即可。为什么要添加 -u
参数?
如果你在本地仓库中使用 git clone
命令克隆了一个远程仓库,并在本地仓库中使用 git checkout -b A
命令创建了一个名为 A
的新分支,并使用 git push A
命令将该分支推送到远程仓库,那么远程仓库将会有一个名为 A
的分支。
但是在使用 git push
命令时,你需要指定要推送的分支和远程仓库的名称。如果你使用 git push A
命令,git
将会尝试将本地仓库中名为 A
的分支推送到远程仓库中名为 A
的分支,但是如果远程仓库中不存在名为 A
的分支,git
将会报错。
因此,如果你想要将本地仓库中的 A
分支推送到远程仓库,并且希望在远程仓库中创建一个名为 A
的分支,应该使用以下命令:
1 | git push -u origin A |
这将会将本地仓库中的 A
分支推送到名为 origin
的远程仓库,并在远程仓库中创建一个名为 A
的分支。
但是现在已经做错了,需要使用代码回撤来修复污染。可以使用 git reflog
命令查看本地仓库的提交历史,找到 develop
分支的提交记录。使用git reset
命令将代码重置到 develop
。
1 | $ git reflog |
找到最后一个 develop
分支的提交记录,记下该提交的哈希值。运行 git reset
命令将本地仓库的 develop
分支重置到该提交记录。例如,如果最后一个 develop
分支的提交记录的哈希值为 abc123
,则可以运行以下命令:
1 | $ git reset --hard abc123 |
运行 git push --force
命令将本地仓库的 develop
分支强制推送到远程仓库。请注意,这将覆盖远程仓库中的 develop
分支,因此请确保已经找到了正确的提交记录。这样就能恢复 develop
分支之前的代码。
1 | $ git push --force origin develop |
首先克隆仓库
1 | git clone git@xxx.git |
创建本地分支,并对应远程分支
1 | git branch -a // 查看分支 |
clone
仓库的 1 天后,有新分支提交到了远程仓库,所以本地没有这个分支。为了查看新分支的代码,需要更新分支:
1 | git remote update origin -p |
在新分支开发代码时,遇到紧急任务需要切换到其他分支修复漏洞。但是新分支的代码才写了一点点还没有 commit
,如果直接 git checkout
会报错,因为新分支的修改没有被存下来或提交。此时可以暂存修改:
1 | git stash // 暂存当前未提交的更改 |
当你完成其他工作并切换回原分支时,可以使用以下命令还原暂存的更改:
1 | git stash pop |
不建议以下的操作,因为这会直接放弃当前分支的修改:
1 | git checkout -f <branch_name> // 切换到另一个分支并丢弃未提交的更改 |
代码改的乱七八糟不想要了:
1 | git reset --hard HEAD |
临时创建了一个文件夹复现了某个问题,需要把这份代码提交到某个仓库。在 git init
之后增加远程仓库:
1 | git remote add origin git@xxx:xxx.git |
因为是临时新建的仓库,所以目前处于 master
分支。执行下面命令,将本地的 master
分支推送到远程的 test
分支(远程没有的话会自动创建):
1 | git push origin master:test // 不加 master: 会报错,因为本地没有 test 分支 |
首先修改小错误,然后:
1 | git add . |
如果此时直接 push
会报错,因为 git status
显示并没有新的内容。如果是提交到自己的分支,在不影响他人的开发的情况下可以直接:
1 | git push origin master:test -f |
这样仓库上只显示一次 commit
记录。如果不是强制推送,那么会遇到下面的问题:
1 | To git@github.xxxx.git |
起因是在这次 push
之前有一次 git commit --amend
修改错别字的操作,当时这个修改是没有提交的。所以再次修改代码并提交时,就遇到了冲突。因为同一文件同样的位置有不同的内容,无法自动合并,所以 push
的时候报错。
此时需要手动 git pull
一下,由用户自己手动 merge
处理冲突。如果是 vscode
的话,看一下哪里修改,如果保留当前版本,点击 accept current change
即可。再次 git add commit push
就没问题了。
此时只需要在当前分支下 pull
代码。把自己的代码完成后,再次提交到分支。假设远程分支叫 B
,基于 B
分支 checkout -b
出 A
分支,在 A
分支写代码。远程分支有更新, merge
了一些修改,让 A
获取到 B
的更新:
1 | git pull origin B |
1 | git branch -vv |
git
基于 B
分支创建了分支 A
,并在 A
分支进行了修改和提交,提交后,vscode
等编辑器内无法看到修改内容。可以通过下述命令查看 A
分支和 B
分支的差异,也就是看 A
分支都改动了哪里。
1 | git diff A B file_path |
commit
记录合并为了保证提交信息的整洁,可以使用 git rebase
命令来将多个 commit
合并成一个,并保留代码的修改。以下是具体步骤:
使用 git log
命令查看你想要合并的 commit
记录的哈希值,例如将以下 3 个 commit
记录合并成一个:
1 | $ git log --oneline |
使用 git rebase -i HEAD~3
命令来打开交互式 rebase
编辑器,其中 HEAD~3
表示要合并的 commit
记录数量。在编辑器中,将第二个和第三个 commit
记录的操作改为 squash
,表示将它们合并到第一个 commit
记录中。例如:
1 | pick 3a2b1c3 Add feature A |
保存并关闭编辑器。
pick
操作会将一个提交应用到当前分支,而 squash
操作会将一个提交合并到前一个提交中,从而将多个提交合并成一个。
git
会自动打开另一个编辑器,让你编辑合并后的 commit
信息。你可以保留第一个 commit
记录的信息,或者修改为新的 commit
信息。保存并关闭编辑器。
使用 git push --force
命令将修改后的 commit
记录推送到远程仓库。注意,由于使用了 --force
参数,这会覆盖远程仓库中的历史记录,因此请确保你的操作不会影响其他人的工作。
git reset
命令用于将当前分支的 HEAD
指针移动到指定的提交,同时可以选择是否修改暂存区和工作目录。--hard
和 --soft
是 git reset
命令的两个选项,它们的区别在于是否修改暂存区和工作目录。
--hard
选项会将 HEAD
指针、暂存区和工作目录都重置为指定的提交。这意味着所有未提交的更改都会被丢弃,工作目录中的文件会被覆盖为指定提交中的文件。
--soft
选项只会将 HEAD
指针移动到指定的提交,而不会修改暂存区和工作目录。这意味着所有未提交的更改都会保留在工作目录中,可以通过 git status
命令查看它们的状态。
一般来说,如果你想完全撤销所有未提交的更改并回到指定的提交,可以使用 --hard
选项。如果你只是想将 HEAD
指针移动到指定的提交,但保留未提交的更改,可以使用 --soft
选项。
这个命令我用的不多,实际场景用到时在补充。
]]>某天闲来无聊的时候,恍惚的发现我竟然还有个博客?主要是太忙了。 其实是自己过于懈怠没学新东西,休息了半年多也没缓过来。尝试推送了一下,也许是某次滚动更新 Linux 的时候升级了 Node.js
,结果 Node.js
版本过高和 hexo
版本不匹配。这就导致博客推送后, github 仓库中全部的 html
文件内容为空。网上绝大多数博客都是写的降级 Node.js
,但这总不是办法,所以不如升级 hexo
来解决问题。
也许在大学的时候遇到过:代码或者软件无法跑通的情况,去问学长或者老师的时候他们就会说,你用的版本太新了,新版本不好用,换成旧版本和我一样就没问题了。总会有人因为可以方便的向老师或者学长提问而屈服于选择旧软件。但从软件开发和维护的角度而言,软件在不断的更新,旧版本无人维护或功能不全。事物在不断的发展,古人都知道不要刻舟求剑,为何抱着老旧软件不放而不选择新软件呢?对于个人使用而言,咬咬牙解决一些 bug 或者版本冲突,问题也就解决了。扯远了,一共两种解决方案,分别是 Node.js
降级或者 hexo
升级,本文推荐后者。
打开 hexo
的官方文档可以看到 hexo
与 Node.js
的版本对应关系:
hexo 版本 | 最低版本 (Node.js 版本) | 最高版本 (Node.js 版本) |
---|---|---|
6.2+ | 12.13.0 | latest |
6.0+ | 12.13.0 | 18.5.0 |
5.0+ | 10.13.0 | 12.0.0 |
4.1 - 4.2 | 8.10 | 10.0.0 |
4.0 | 8.6 | 8.10.0 |
3.3 - 3.9 | 6.9 | 8.0.0 |
3.2 - 3.3 | 0.12 | 未知 |
3.0 - 3.1 | 0.10 或 iojs | 未知 |
0.0.1 - 2.8 | 0.10 | 未知 |
由于我的博客是在 20 年初迁移到新电脑的,hexo
是 3.9.0 的旧版本,而 Node.js
被更新到 20.3.1,也就是版本不匹配,导致博客一波被清空,各种 html
文件没有任何内容。
打开浏览器搜索,这个就是绝大多数的解决方案。这里建议使用 nvm
管理 Node.js
的版本,之后对 nvm
换源,并安装各个版本的 Node.js
。
1 | sudo pacman -Ss nvm // 安装 |
通过上述命令,如果没有遇到其他奇怪的 bug 的话,Node.js
12.0 版本就被安装成功了。由于 hexo
默认使用系统安装的 Node.js
,而不是 nvm
安装的 Node.js
。所以在每次更新博客时需要调用 nvm
切换 Node.js
版本进行推送:
1 | nvm use 12.0.0 // 切换版本 |
而且由于 hexo
默认使用系统安装的 Node.js
,这个版本的 Node.js
不被 nvm
所管理,所以每次推送必须使用 use
命令来切换版本,这个就很繁琐,不够优雅。下述命令是无法起作用的:
1 | nvm alias default 12.0.0 |
此时虽然能推送博客,但由于 hexo
版本过低,在推送时仍然会提示有异常信息:ERROR Plugin load failed: hexo-cli
,反正就看着很不爽。
此外,我使用了 fish
终端,这个终端安装和使用 nvm
有些许的费劲,这里给个教程,防止未来某天我自己忘掉。
如上所述,软件升级是不可避免的,每次推送博客需要使用 nvm
去切换版本也过于繁琐。那不如直接升级 hexo
一劳永逸?
我当时是卸载了全部的 npm,Node.js hexo
重新安装。备注:nvm
是 Node.js
的版本管理工具,npm
是 Node.js
下面的库安装工具,类似 python 的 pip
:
1 | npm uninstall hexo-cli // 卸载 hexo |
之后,给 npm
换源,并安装 hexo
即可,备注:如果安装无响应或无权限,给下面的命令加个 sudo
即可。
1 | npm config set registry https://registry.npm.taobao.org // 换源 |
但是呢我发现,安装后的 hexo
依然是 3.9.0 的旧版本,所以我选择给 hexo
升级,同样,下面的命令如果无法执行时,就加个 sudo
。
1 | npm cache clean -f //清除缓存 |
这样,就升级了 hexo
,本文升级到了 6.3.0,正好适配最新的 Node.js
,推送博客没有任何问题。
由于我的博客主题配置文件好多年没有更新,而最新的 hexo
和博客的 _config.yaml
还有一个冲突:external_link
报错,只需要打开博客配置文件 _config.yaml
,找到:
1 | external_link: true # Open external links in new tab |
修改为:
1 | external_link: |
至此,hexo
推送博客时没有任何报错,清清爽爽。
当时本人对于如何解决这个问题也是一头雾水,胡乱的查阅各种文档,走了很多弯路,试了很多错,在无数次卸载重装后解决了问题。期间一个手滑把 node_modules
给删除了,后面重新安装了数学渲染的库,但 equation
和 aligned
这种环境依然无法被正确渲染,处于乱码的状态,按照这一文章可以正确修复行间公式无法渲染的漏洞。
该上班了,学到新知识后也许博客可以勤快的更新起来?哦对还有,查阅文档时看到的一个乐子:
写一个正经的致谢吧,作为学生时代的一个小结尾。毕业论文里的致谢太八股了,前一半内容一定要大幅的感谢老师,感谢老师给的机会和培养。后四分之一写实验室同学,在后面写父母。不能感谢自己,最后一段感谢论文评委,过于官方的东西没意思的很。所以写一些不能放到论文里面的致谢。
想来想去一时间不知道从哪里开始谢起,先感谢一下 Carol 老师吧,写的 xduts
模板和接口过于强大,让我能愉快的使用 TeX
写硕士毕业论文,不用再花费过多的精力去调整复杂的格式,使用期间也没有遇到任何排版上问题和困难,还耐心的解答了我的各种疑问。Carol
老师原话:毕业论文除内容外的所有东西他都会,比如 pdf 裁边这种很微小但又很细节的东西。在邻近毕业的时候,我没有在微信上走任何形式去感谢任何人,唯独卡老师是个例外。当时说:希望毕业后在工作中还能遇到你这样的人,这大概是我能想到的最高赞扬了。当时还说等我工作赚钱了一定去打赏 xduts
。在某天忽然想起来时,陆陆续续打赏了 800 大洋,就当赞助用爱发电的开源项目了。
想起来 2020 年研究生入学的时候,那时候充满了惶恐和焦虑。当时年轻也不知道如何去选择一个好组和一个好老师,听说了老师的各种事迹后焦虑到呼吸困难。入学后直线加深了我的焦虑,时常担忧未来而在夜里无法入眠。感谢我亲爱的 ykc 师兄不知道和我们在夜里交流了多少次,研一很多次,研二很多次,研三很多次,在实验室,在操场,在小饭馆。虽然他也肩负很大的压力,但也尽可能的舒缓我们的情绪,每次和他聊完都感觉身心安定,坚定了读下去的心。也感谢大师兄 wz,帮我们顶住了老师的压力,每次都尽力的和我们讨论问题,帮我们度过一次次的难关,在其他生活琐事和医食住行等方面也给了我们很多帮助。
在 21 年 11 月的时候,步入了人生的低谷,整日浑浑噩噩沉迷于无所事事。感谢我的师弟 wzb,和我一起开发华为算子中的难点,帮我分担了很大的压力。在今年的 1 月和 2 月帮我跑毕业论文中的部分实验,再次帮我分担压力和焦虑,让我有时间和经历去写毕业论文。真的十分感谢,我当时还在想,毕业后要不要给师弟买个 PS5。
除此之外,由于进的组人数极少且没有任何形式的合作和交流,我更多的社交也都在互联网上了。感谢一个水群的网友,来自五湖四海但因写代码相识,和你们聊天消耗了我日常 70% 的话语,代码技术聊到人生哲学,甚至偶尔搞搞黄色和八卦,让我感觉没那么孤独。
十分感谢给予我经济援助的小伙伴们。研一下半年的经济状况过于贫困,也不好意思去找家里要钱,每天都在芹菜、豆芽、粉条、白菜、西葫芦、豆腐和西红柿之间轮换,因为很便宜。连续吃了几个月之后导致我现在看到这些食物依然反胃,迫于无奈选择了靠程序辅导去赚点钱,感谢你们一笔一笔的经济援助和支持,让我有足够的钱去吃肉、买新衣服、回家能坐高铁,让我活的更加体面。你们人都很好,也希望你们在告别短暂的计算机编程之后,能迎来更好的人生。
尤其感谢期间认识的 tcr 小姐姐,2022 年的 8 9 月份,找工作压力很大期间还生了一次大病,她不断的安慰和鼓励我,每次都发很多很多的话和语音,给我很多建议,希望我坚持下去打败困难,对于我理解不了的内容还打电话特意解释。大恩不言谢,日后必定请吃饭,请最贵的那种。之后感谢 qq,hkx 和 bmh,不嫌弃和我这样的发疯人士聊天,承担了我大多数孤独和压抑的情绪,在我多次发疯后依然不介意尝试去疏导我的情绪和压力。hkx 在听说我读研的遭遇后,二话不说给我买了很多零食,qq 在知道我失眠后给我邮寄了酸枣仁,原来我还不是孤魂野鬼。
昨晚在写论文摘要的时候,想起来一件事情。18 年打比赛的时候,最后一天的凌晨 4 点累的不知道自己是谁,就去跟老师说,我写不动了,你能帮我写下摘要吗?老师说行。我直接睡了过去,再次醒来就是 8 点了。老师 40 多岁,还是通宵帮我把所有事情都弄好,我永远像个孩子一样。后来每次写论文摘要的时候,都会想起他的样子。我很感谢我的本科老师,他把我带入了新的生活和世界,让我学到了编程和建模,从此走上了不一样的道路。我还记得他说过的话:学以致用。我还记得最感动的一次, 大三的时候我在犹豫要不要去打比赛,他说:如果我要去,他就把最后一个名额留给我,人我随便挑;如果我不去,最后一个名额也不准备带别人了,当时感动了很久。那年全校 100 多个队伍参赛,只有 4 个一等奖,我是其中之一,那年我的获奖证书被放到学校招新的海报中,也一步步的保研成功。
也许,人生大部分时候都是痛苦的,只有少数的幸福时刻,就像河面上的少许的波光粼粼。但就是这些少许的亮光,能让河流看起来更美,能照亮绝大多数的平庸或难熬时刻,温暖着我们继续走下去。
甚至还想感谢 XM,给我提供了人生的第一份工作,开了极具诱惑力的薪资,还是我很向往的工作方向。本科学的 A 方向,对 B 方向感兴趣,研究生学的 C 方向,对 E 方向感兴趣,但没有 E 方向的相关知识储备和项目经验,所以找工作准备的 D 方向。最后 XM 提供的工作方向是 E,兜兜转转还是遇到了最喜欢的方向,真的十分满足。其实还有一些宿命论的味道,我第一门学习的编程语言大一开设的 C++ 课程,之后对编程萌发了兴趣转专业去学计算机,未来的工作方向也是 C++,很长一段时间内都要靠它吃饭了。
]]>6年前的12月1号
体育课下课后在操场跑了几圈
背着当时的初中用过来的破旧书包,去兰园一楼吃了顿鸭扒饭
晚上去自习室学高数,分部积分
之后每年的12.1都会回忆起那普通的一天,宣告着这一年还有最后一个月
今年也不例外
12岁的时候,觉得动画片这么好看大人怎么不喜欢看呢。总以为20岁以后时间密度和快乐会和童年一样,不断打开的新鲜生活是应接不暇的,每一件事都会历历在目,念念不忘,生活也一定五彩斑斓,总有新领域等待我去玩耍。
20岁后的这几年才明白,因为各种主客观的壁垒,成年以后的人生在收窄,只能在一个地方永远停留下去,重复的事物越来越多,时间在重复里飞速进行,总觉得根本没做什么一年就过去了。
人不能同时拥有青春和感受青春,也大概理解了年轻真好的意思,年轻人还有时间去改变一些东西,成年人如果想去改变自己的现状,可以,但会付出很大很大的成本与代价。
年历仍是在更迭的,但每年都像被水浸泡过一般,界限逐步模糊,无法像幼时那样能一一分得清楚,有期待感。只觉得这几年里都是循环的情绪,堆砌的熟稔,往复的麻木,仿佛依靠惯性在活着。即便偶遇意外的惊喜或猝然的悲恸,事后冷静想想,也好像都是从前早已领教过的二手货。
今年去西安的时候,下了大雪,我寻思着瑞雪兆丰年;今天完成了找工作的最后一步,寄三方,又下了大雪;两场大雪,也许宣告了青春的结束。
之前不顺心的时候,总是想着努努力忍一忍,以后去个好地方永远的告别这里,高中是这样,研究生也是这样。
最近在忙毕设,学校的压迫程度,资本家都自叹不如,期望在毕业之前我的博客还能有所技术产出。
]]>寒气逼人的惨淡秋招终于 tnnd 的结束了,4月中旬开始投递,10月中旬拿到 offer,耗时6个月。就业形式异常艰难,简历挂,笔试挂,面试挂,感谢信收割机。一种被累垮的感觉。
大家仿佛都是在 3 月份开始了背八股文,我当时觉得没啥意思就顺手参加了个比赛。本人找的算法岗,由于懒惰和各种原因,在第一场面试开始的时候,我都没有背八股,连梯度消失这样的问题偶没有回答上来。拖延到6月下旬才开始背八股,背的时间不长,断断续续的一个月,每次面试前看看笔记就行,剩下的随缘发挥。
但同组的就不一样了,他们投的 java 开发,从 java 基础,多线程,JVM,框架,分布式,数据库,网络,系统等等等等他们都要背,如果说我要掌握的知识一周就可以背完,他们的知识至少要背十周。找工作的时候,他们是睡在实验室的。
最艰难的时候是从7月30号开始的,我清楚的记得那天能投10家公司,除了快手通知我面试外,其余全挂,可惜快手也是一面就挂。在8月的某天下午和晚上连开四场笔试,极限操作,头晕脑胀,手在颤抖,从8月到9月,持续一个月不间断的面试和笔试。这辈子也不想在回忆这种头晕的感觉。
来形容一下某头部大厂的面试,开局两个 hard 级别的 leetcode 题,我写上来了。结果以为后面会很顺畅,结果呢,面试全程就三个字,嗯,啊,好,面试结束。后来才知道他想用代码题来劝退我,早知道我就不写了。在形容一下某硬件大厂的面试:你了解过XX吗,我说没有;你用过XX吗,我说不好意思只听说过。面试直接结束,全程不到5分钟,至于简历里写了什么,你是做什么的一概不问。
京东,网易和腾讯的题目都是令人劝退的难度。如果说数学不会还能写个解,编程不会甚至不能写个空格。我还清楚的记得今年的网易,京东和百度都在围绕 red
这个字符串出题,红色意味着警告,可能告诉我们今年形式很严峻吧。蚂蚁笔试干脆交了白卷,后续的笔试也没有参加,不是看不上蚂蚁,是我真的累了;字节笔试一个不会,瞪着屏幕发呆两小时的感觉很难受。7月投了多少公司,8月就收了多少感谢信。
我在17年因为喜欢代码转专业到了计算机。但是秋招的很长一段时间内患上了代码 PTSD,一看到代码题目就头晕,想睡,本人十分厌恶刷题,找不到丝毫写代码的乐趣,也没有学习的乐趣,一股为了学习而学习的中学味,令人呕吐。以至于后来面试的时候,明明很简单的题,我的下意识反应都是我不会,很简单的题我会想的很复杂。比如求最长回文子串,明明是一个很简单的暴力模拟题,我看到最这个字就往动态规划那边去想,结果写出来的程序又臭又长,我自己都看不下去,写到一半干脆说了不会。
就像准备了很久的高考,上了考场发现自己害怕,不会,也不敢动笔。百度是这样,快手是这样,滴滴也是这样;我是这样,同组得这样,舍友也是这样,大家都被拖的很累。经济形势不好,今年的就业形势到处是槽点。百度和快手的面试官态度是最好的,夸一下。
今年最大的意外就是:本科学的A方向,研究生是B方向,准备的C方向,最后的工作是D方向。至于我能拿offer跟我实力没有半毛钱关系,计算机卷的起飞,我被挤到了芯片,医疗,金融,VR等各个方向,没有一个和计算机相关。面试凭实力?错,全凭运气,有的厂的笔试很简单大二学生都会,面试也能聊得来;有的令人想直接关了屏幕再你妈的见。如果可以,我还是想回到大学的校园里,好好补补基础课。面到最后发现还是大学课程的基本功,可惜大学的黄金时光被我荒废。
感谢各位大哥的帮助,尤其是田学姐数次救我狗命于水火之中。
]]>上一次正儿八经写博客是今年 2 月,5 月做了个比赛总结,其余的博客竟然都是刷题和算法,实属无聊。艰难的日子已经过去,准备学点模型部署相关的东西以及参与一个实际的开源项目,争取数据、算法和工程全链路打通。众所周知,对于一个不是很常用的东西,学完就忘,如 spark, Go
等学过的但很少用的东西,已经被我抛到九霄云外了。所以,这次学完模型的 trace
之后,尝试部署一些能实际运行的软件。
TorchScript
是 PyTorch
的 JIT
实现。JIT
全程是 Just In Time Compilation,也就是即使编译。在深度学习中 JIT
的思想更是随处可见,最明显的例子就是 Keras
框架的 model.compile 创建的静态图。
TensorFlow1.x
中的 session.run
那样。那么那到底 JIT
有哪些特性,使得 torch
这样的动态图框架也要走 JIT
这条路呢?或者说在什么情况下不得不用到 JIT
呢?下面主要通过介绍 TorchScript
来分析 JIT
到底带来了哪些好处。
JIT
是 Python
和 C++
的桥梁,我们可以使用 Python
训练模型,然后通过 JIT
将模型转为语言无关的模块,从而让 C++
可以非常方便得调用,从此「使用 Python
训练模型,使用 C++
将模型部署到生产环境」对 PyTorch
来说成为了一件很容易的事。而因为使用了 C++
,我们现在几乎可以把 PyTorch
模型部署到任意平台和设备上:树莓派、iOS、Android 等等。不然每次都要通过 python
调用模型,性能会大打折扣。
既然是为部署生产所提供的特性,那免不了在性能上面做了极大的优化,如果推断的场景对性能要求高,则可以考虑将模型(torch.nn.Module
)转换为 TorchScript Module
,再进行推断。有两种方式可以转换:
TorchScript Module
的更简单的办法是使用 Tracing
,Tracing
可以直接将 PyTorch
模型(torch.nn.Module
)转换成 TorchScript Module
。「 trace
」顾名思义,就是需要提供一个「输入」来让模型 forward
一遍,以通过该输入的流转路径,获得图的结构。这种方式对于 forward
逻辑简单的模型来说非常实用,但如果 forward
里面本身夹杂了很多流程控制语句,就会存在问题,因为同一个输入不可能遍历到所有的逻辑分枝。而没有被经过的分支就不会被 trace
。TorchScript Language
来定义一个 PyTorch JIT Module
,然后用 torch.jit.script
来将他转换成 TorchScript Module
并保存成文件。而 TorchScript Language
本身也是 Python
代码,所以可以直接写在 Python
文件中。对于 TensorFlow
我们知道不能直接使用 Python
中的 if
等语句来做条件控制,而是需要用 tf.cond
,但对于 TorchScript
我们依然能够直接使用 if
和 for
等条件控制语句,所以即使是在静态图上,PyTorch
依然秉承了「易用」的特性。首先定义一个简单的模型:
1 | import torch |
我们可以绑定输入对模型进行 trace
:
1 | import torch |
可以看到没有出现 if-else
的分支, trace
做的是:运行代码,记录出现的运算,构建 ScriptModule
,但是控制流就丢失了。然后流程丢失并不是好事,在 trace
只会对一个输入进行处理的情况下,对不同的输入得到的结果是不一样的,因为输入只会满足一个分支,因此 trace
的程序也只包含一个分支。
1 | import torch |
因此,我们认为这样的 trace
没有泛化能力。而这种现象普遍发生在动态控制流中,即:具体执行哪个算子取决于输入的数据。
if x[0] == 4: x += 1
是动态控制流model: nn.Sequential = ... [m(x) for x in model]
不是1 | class A(nn.Module): |
在之后的文章中,会介绍如何使 trace
具备泛化能力。
script
方法直接分析 python
代码进行转换:使用他们提供的 script
编译器,将 python
的代码进行语法分析,并重新解释为 TorchScript
。
1 | import torch |
TorchScript
代码可以被它自己的解释器(一个受限的 Python
解释器)调用。这个解释器不需要获得全局解释锁GIL,这样很多请求可以同时处理。python
的语言。TorchScript
提供的表示可以做编译器优化,做到更有效地执行。TorchScript
可以与其他后端/设备运行时进行对接,他们只需要处理整个项目,无需关心细节运算。通过上文我们可以了解到:
trace
只记录走过的 tensor
和对 tensor
的操作,不会记录任何控制流信息,如 if
条件句和循环。因为没有记录控制流的另外的路,也没办法对其进行优化。好处是 trace
深度嵌入 python
语言,复用了所有 python
的语法,在计算流中记录数据流。
script
会去理解所有的 code
,真正像一个编译器一样去进行词法分析语法分析句法分析,形成 AST
树,最后再将 AST
树线性化。script
相当于一个嵌入在 Python/Pytorch
的 DSL
,其语法只是 Pytorch
语法的子集,这意味着存在一些 op
和语法 script
不支持,这样在编译的时候就会遇到问题。此外,script
的编译优化方式更像是 CPU
上的传统编译优化,重点对于图进行硬件无关优化,并对 if
、loop
进行优化。
在大模型的部署上 trace
更好,因为可以有效的优化复杂的计算图,如下所示:
1 | class A(nn.Module): |
因为 script
试图忠实地表示 Python
代码,所以即使其中一些是不必要的。例如:并不能对 Python
代码中的某些循环或数据结构进行优化。如上例,所以它实际上有变通方法,或者循环可能会在以后的优化过程中得到优化。但关键是:这个编译器并不总是足够聪明。对于复杂的模型, script
可能会生成一个具有不必要复杂性且难以优化的计算图。
tracing
有许多优点,事实上,在 Facebook/Meta
部署的分割和检测模型中,tracing
是默认的选择,仅当必要的时候使用 scripting
。因为 trace
不会破坏代码质量,可以结合 script
来避免一些限制。
python
是一个很大很动态的语言,编译器最多只能支持其语法功能和内置函数的子集,同理,script
也不例外。这个编译器支持 Python
的哪个子集?一个粗略的答案是:编译器对最基本的语法有很好的支持,但对任何更复杂的东西(类、内置函数、动态类型等)的支持度很低或者不支持。但并没有明确的答案:即使是编译器的开发者,通常也需要运行代码,看看能不能编译去判断是否支持。
所以不完整的 Python
编译器限制了用户编写代码的方式。尽管没有明确的约束列表,但可以从经验中看出它们对大型项目的影响:script
的问题会影响代码质量。很多项目只停留在了代码能 script
成功这一层面,使用基础语法,没有自定义类型,没有继承,没有内置函数,没有 lambda
等等的高级特性。因为这些高级的功能编译器并不支持或者部分支持,就会导致在某些情况下成功,但在其他情况下失败。而且由于没有明确的规范哪些是被支持的,因此用户无法推理或解决故障。因此,最终用户会仅仅停留在代码成功搬移,而不考虑可维护性和性能问题,会导致开发者因为害怕报错而停止进一步的探索高级特性。
如此下去,代码质量可能会严重恶化:垃圾代码开始积累,因为优良的代码有时无法编译。此外,由于编译器的语法限制,无法轻松进行抽象以清理垃圾代码。该项目的可维护状况逐渐走下坡路。如果认为 script
似乎适用于我的项目,基于过去在一些支持 script
的项目中的经验,我可能会出于以下原因建议不要这样做:
以多任务检测器为例:
因此,这个问题的现状是:script
迫使你编写垃圾的代码,因此我们仅在必要时使用它。
trace
让模型的 trace
更清楚,对代码质量有很少的影响。
如果模型不是以 Pytorch
格式表示的计算图,则 script
和 trace
都不起作用。例如,如果模型具有 DataParallel
子模块,或者如果模型将张量转换为 numpy
数组并调用 OpenCV
函数等,则必须对其进行重构。除了这个明显的限制之外,对 trace
只有两个额外的要求:
输入/输出格式是 Tensor
类型时才能被 trace
。但是,这里的格式约束不适用于子模块:子模块可以使用任何输入/输出格式:类、kwargs
以及 Python
支持的任何内容。格式要求仅适用于最外层的模型,因此很容易解决。如果模型使用更丰富的格式,只需围绕它创建一个简单的包装器,它可以与 Tuple[Tensor]
相互转换。
shape
。tensor.size(0)
是 eager
模式下的整数,但它是 tracing mode
下的 tensor
。这个差异在 trace
时是必要的,shape
的计算可以被捕获为计算图中的算子。由于不同的返回类型,如果返回的一部分是 shape
是整数则无法 trace
,这通常可以简单的解决。此外,一个有用的函数是 torch.jit.is_tracing
,它检查代码是否在 trace
模式下执行。
我们来看个例子:
1 | 1), torch.rand(2) a, b = torch.rand( |
在 trace f2
函数时,lex(x)
是一个定值而非 tensor
,这样在传入其他长度的数据时就回报错。除了 len()
,这个问题也可能出现在:
.item()
将张量转换为 int/float
。Torch
类型转换为 numpy/python
原语的任何其他代码。tensor.size()
在 trace
期间返回 Tensor
,以便在图中捕获形状计算。用户应避免意外将张量形状转换为常量。使用 tensor.size(0)
而不是 len(tensor)
,因为后者是一个 int
。这个函数对于将大小转换为张量很有用,在 trace
和 eager
模式下都可以使用。对于自定义类,实现 .size()
方法或使用 .__len__()
而不是 len()
,不要通过 int()
转换大小,因为它们会捕获常量。
这就是 trace
所需要的一切。最重要的是,模型实现中允许使用任何 Python
语法,因为 trace
根本不关心语法。
1 | def f(x): |
注意这种代码在 trace
时不会报错,只有 warning
的输出,因此我们要特别关注。trace
和 script
都有各自的问题,最好的方法是混合使用他们。避免影响代码质量,主要的部分进行 trace
,必要时进行 script
。如果有一个 module
里面有很多选择,但是我们不希望在 TorchScript
里出现,那么应该使用 tracing
而不是 scripting
,这个时候,trace
将内联 script
模块的代码。
1 | import torch |
我们简化一下:
1 | model.submodule = torch.jit.script(model.submodule) |
对于不能正确 trace
的子模块,可以进行 script
处理。但是并不推荐,更建议使用 @script_if_tracing
,因为这样修改 script
仅限于子模块的内部,而不影响模块的接口。使用 @script_if_tracing
装饰器,在 torch.jit.trace
时,@script_if_tracing
装饰器可以通过 script
编译。通常,这只需要对前向逻辑进行少量重构,以分离需要编译的部分(具有控制流的部分):
1 | def forward(self, ...): |
只 script
需要的部分,代码质量相对于全部 script
被破坏的很少,被 @script_if_tracing
装饰的函数必须是不包含 tensor
模块运算的纯函数。因此,有时需要进行更多重构:
1 | # Before: |
同样的,我们可以在 script
中嵌套 trace
:
1 | model.submodule = torch.jit.trace(model.submodule, submodule_inputs) |
这里的子模块是 trace
,但是实际中并不常用,因为会影响子模块的推理(当且仅当子模块的输入和输出都是 tensor
时才适用),这是很大的限制。但是 trace
作为子模块的时候也有很试用的场景:
1 | class A(nn.Module): |
@script_if_tracing
不能处理这样的控制流,因为它只支持纯函数。如果子模块很复杂不能被 script
,使用 trace
trace
子模块是很好的选择,这里就是 self.submodule2
和 self.submodule1
,类 A
还是要 script
的。
事实上,对于大多数视觉模型,动态控制流仅在少数易于编写 script
的子模块中需要。script
相对于 trace
,有两个有点:
trace
无法处理trace
只支持 forward
方法,script
支持更多的方法实际上,上述两个功能都在做同样的事情:它们允许以不同的方式使用导出的模型,即根据调用者的请求执行不同的运算符序列。下面是一个这样的特性很有用的示例场景:如果 Detector
是 script
化,调用者可以改变它的 do_keypoint
属性来控制它的行为,或者如果需要直接调用 predict_keypoint
方法。
1 | class Detector(nn.Module): |
这种要求并不常见。但是如果需要,如何在 trace
中实现这一点?我有一个不是很优雅的解决方案:Tracing
只能捕获一个序列的算子,所以自然的方式是对模型进行两次 Tracing
:
1 | det1 = torch.jit.trace(Detector(do_keypoint=True), inputs) |
然后我们可以为它们的模型设置别名(以不重复存储),并将两个 trace
合并到一个模块中以编写 script
:
1 | det2.submodule.weight = det1.submodule.weight |
还可以使用单元测试来判断 trace
是否成功:
1 | assert allclose(torch.jit.trace(model, input1)(input2), model(input2)) |
此外,还可以通过优化程序,避免掉不必要的特殊情况:
1 | if x.numel() > 0: |
此外还需要注意设备问题,在 trace
期间会记录使用的设备,而 trace
不会对不同的设备进行泛化,但是部署时都会有固定的设备,这个问题不用担心。
1 | def f(x): |
trace
有明显的局限性:本文大部分时间都在讨论 trace
的局限性以及如何解决它们。我实际上认为这是 trace
的优点:它有明确的限制和解决方案,所以你可以推断它是否有效。相反, script
更像是一个黑匣子:在尝试之前没有人知道它是否有效。
trace
具有较小的代码破坏范围: trace
和 script
都会影响代码的编写方式,但 trace
的代码破坏范围要小得多,并且造成的损害要小得多:
trace
中混合 script
,但可以只更改受影响模块的内部实现,而不是它们的接口。另一方面, script
对以下方面有影响:
这也是为什么 script
会对代码质量造成很大损害的原因。Detectron2
支持 script
,但不推荐其他大型项目以可 script
且不丢失抽象为目标,因为这实在有点难度,除非它们也能像阿里巴巴那样得到 PyTorch
团队的支持。
PyTorch
深受用户喜爱,最重要的是编写 Python
控制流。但是 Python
的其他语法也很重要。如果能够编写 Python
控制流( 使用 script
)意味着失去其他优秀的语法,我宁愿放弃编写 Python
控制流的能力。事实上,如果 PyTorch
对 Python
控制流不那么执着,并且像这样(类似于 tf.cond
的 API
)为我提供了诸如 torch.cond
之类的符号控制流:
1 | def f(x): |
然后 f
可以正确 trace
,不再需要担心 script
。
1 | traced.save('wrapped_rnn.pt') |
这几天接连遇到了一些双指针的问题,但是说实话,并没有从这些题中看到一种通用的东西,也就不是能很好的做一个总结,但不得不说双指针是一个很神奇的东西,所以做一道记一道吧。
快慢指针也是双指针,但是两个指针从同一侧开始遍历数组,将这两个指针分别定义为快指针(fast)和慢指针(slow),两个指针以不同的策略移动,直到两个指针的值相等(或其他特殊条件)为止,如fast每次增长两个,slow每次增长一个。
常用于链表问题,如:slow开始移动,由于移动速度是 fast 的一半,那么 fast 移动到链表的末尾时,slow 就位于链表的中央,可以用这这种方法求链表的中点。
给你一个升序排列的数组 nums
,请你原地删除重复出现的元素,使每个元素只出现一次 ,返回删除后数组的新长度。元素的相对顺序应该保持 一致 。
由于在某些语言中不能改变数组的长度,所以必须将结果放在数组 nums
的第一部分。更规范地说,如果在删除重复项之后有 k
个元素,那么 nums
的前 k
个元素应该保存最终结果。将最终结果保存到 nums
的前 k
个位置后返回 k
。
不要使用额外的空间,你必须在原地修改输入数组 并在使用 $O(1)$ 额外空间的条件下完成。
这个题乍一看还真不会,于是果断看了题解:
nums[fast]
,此时我们让 nums[slow]=nums[fast]
,slow 和 fast 同时向后移动即可1 | class Solution { |
给定一个链表,返回链表开始入环的第一个节点。 从链表的头节点开始沿着 next 指针进入环的第一个节点为环的入口节点。如果链表无环,则返回 null。
为了表示给定链表中的环,我们使用整数 pos 来表示链表尾连接到链表中的位置(索引从 0 开始)。 如果 pos 是 -1,则在该链表中没有环。注意,pos 仅仅是用于标识环的情况,并不会作为参数传递到函数中。
1 | 输入:head = [3,2,0,-4], pos = 1 |
首先明确一点,使用哈希存储地址肯定可以做出来,但这里是为了熟悉双指针。
由于 fast 移动的距离是 slow 的二倍,因此:
\brgin{equation}
a+n(b+c)+b = 2 [a+m(b+c) + b] \\
\Rightarrow a = (n-2m)(b+c) - b
\end{equation}
也就是说,$a$ 的长度等于整数倍的环的长度减去 $b$ 的长度。得到这个等式后,我们让一个指针从 head
出发,slow
指针从相交处出发,两者相交时,就是环的入口节点。
1 | class Solution { |
对撞数组适用于有序数组、利用数组两侧求最值、只用数组内的两个元素等问题,应该第一时间想到用对撞指针解题。
给定一个长度为 n
的整数数组 height
。有 n
条垂线,第 i
条线的两个端点是 (i, 0)
和 (i, height[i])
。找出其中的两条线,使得它们与 x
轴共同构成的容器可以容纳最多的水。返回容器可以储存的最大水量。
1 | class Solution { |
给定数组 people
。people[i]
表示第 i
个人的体重,船的数量不限,每艘船可以承载的最大重量为 limit
。每艘船最多可同时载两人,但条件是这些人的重量之和最多为 limit
。返回承载所有人所需的最小船数。示例:
1 | 输入:people = [3,2,2,1], limit = 3 |
我们假设一种极端情况,数组排序后是 [1, 2, ..., n-2, n-1]
,而船能容纳的极限是 n
。那么,最佳分配就是让 1
和 n-1
在一起,2
和 n-2
在一起。此时只用两条船。虽然 1
可以和 2
在一起,那么要承载 n-2
和 n-1
,就需要 3 条船。
基于贪心的思想,我们应该尽可能的把轻的和重的分配到一起,来减少船的使用数量,首先对数组排序:
l=0, r=n-1
nums[l] + nums[n-1] <= limit
,就让这两个人坐一起,此时 l++
r--
,因为数组末尾的必须上一个人,而数组左侧的人选择性上或不上 1 | class Solution { |
给定一个长度为 $n$ 的字符串,其中,W
表示白色的球,R
表示红色的球,如果把红色的球放到一起,请问最少移动多少次?示例:
1 | 输入:s = "WRRWRW" |
一个很经典的双指针题目,注:2022年微软秋招笔试题原题。这个题解有点长,日后完善。
1 | class solution{ |
闲来无事,在面经上看到了一个问题:在物理机只有 1G 内存的情况下,能否 malloc
出 4G 大小的数组。奇怪的是,这个问题在网上搜不到特别好的解答,于是突发奇想试着解答一下。
先直接给出结论,malloc
的内存位于堆区,顺便简单了解下 C/C++ 的内存分布。对于 C/C++ 语言,程序内存分布如下:
重点是其中的栈区和堆区:
栈区:程序自动向操作系统申请分配以及回收,速度快,使用方便,但是程序员无法控制。如果分配的内存超过了栈区的最大空间,会抛出栈溢出错误。const 局部变量也存储在栈区,栈区向地址减小的方向增长。系统为变量在栈上申请内存后,CPU 需要不断地判断变量是否已结束使用的生命周期,如果生命周期结束,系统就会释放为这个变量申请的栈内存,这样一来随着在栈上申请的变量增多,会对 CPU 造成额外的消耗。
堆区:程序员向操作系统申请一段内存,当系统收到程序的申请时,会遍历一个记录空内存结点的链表,找到第一个空间大于或等于所申请空间的堆结点,将该空闲结点从链表中删除,并将该结点的空间分配给程序,如果链表中空闲结点的空间大于申请空间的大小,系统会自动将对于的部分放入空闲链表中,故容易造成内存的碎片化,分配速度较慢,地址不连续。且堆区的内存由程序员申请,也必须由程序员负责管理和释放,否则会导致内存泄漏,堆的增长方向与内存地址的增长方向相同,因此在堆区上申请空间理论上是没有大小限制的,但是受安装内存条的大小和系统以及其他程序的占用,不是无限大的。不像栈上的变量那样,需要消耗 CPU 资源判断变量的生命周期,所以不会对 CPU 造成额外的消耗,这也是程序员申请堆上内存的优点。
对于栈来讲,是由编译器自动管理,无需我们手工控制;对于堆来说,释放工作由程序员控制,容易产生内存泄露。碎片问题:对于堆来讲,频繁的 malloc/free
势必会造成内存空间的不连续,从而造成大量的碎片,使程序效率降低。对于栈来讲,则不会存在这个问题,因为栈是先进后出的队列,他们是如此的一一对应,以至于永远都不可能有一个内存块从栈中间弹出,在他弹出之前,在他上面的后进的栈内容已经被弹出。
在了解 malloc
分配到的堆区大小取决于内存剩余的空闲空间后,再来研究能不能分配出大于空闲空间的数据。先给出结论,在虚拟内存足够大的情况下,1G 大小的内存可以开辟出 4G 的数组。虚拟内存是一个假象的内存空间,在程序运行过程中虚拟内存空间中需要被访问的部分会被映射到物理内存空间中。虚拟内空间大只能表示程序运行过程中可访问的空间比较大,不代表物理内存空间占用也大。
malloc
可以申请到超出机器物理内存的大小,为什么说是一部分呢,因为可申请的内存不仅和已占用的内存相关,还和机器的 swap space
(虚拟内存)相关,事实上在你给你机器装 Linux 系统的时候应该碰到过,那就是磁盘分区的时候会有一个 swap
设定,只需要知道它是一种挂载在物理硬盘上,用来存放一些不太频繁使用的内存,是一种低速的物理内存的扩展。
当物理内存不够用时,原先一些物理内存中不常访问的内容会被转移到这里以让出空间给其它进程。所以 swap
空间也可以被 malloc
申请到。malloc
这个时候申请了内存,但没有完全申请,这就涉及到一个叫做 Lazy Allocation
的东东,当你使用 malloc
时,系统并没有真正从物理内存中分配,而是等到进程要操作时才提供 allocation
。
因此,正是因为虚拟内存的存在,通过虚拟内存可以让程序可以拥有超过系统物理内存大小的可用内存空间。
这是我研究生第一节课老师讲述的内容(顿时我就觉得那老师才是真正的计算机学者):虚拟内存为每个进程提供了一个一致的、私有的地址空间,它让每个进程产生了一种自己在独享主存的错觉。使用虚拟寻址,CPU 需要将虚拟地址翻译成物理地址,这样才能访问到真实的物理内存。程序可以使用一系列相邻的虚拟地址来访问物理内存中不相邻的大内存缓冲区,不同进程使用的虚拟地址彼此隔离。一个进程中的代码无法更改正在由另一进程或操作系统使用的物理内存,如果各个进程之间没有独立的地址空间,一个进程由于执行错误指令或是恶意代码都可以直接修改其它进程的数据,甚至修改内核地址空间的数据,这是操作系统所不愿看到的。
]]>本文集中写链表的反转问题,因为其他的链表相交、链表数量等问题比较简单,即使没啥算法经验也能写个差不多,而链表反转也算一种经典的递归问题。这个文章的文字描述太乱了,有时间回来补图。
给你单链表的头节点 head
,请你反转链表,并返回反转后的链表。反转链表有两种实现方式,一种是迭代式实现,一种是通过递归实现。先来看通过迭代实现:迭代的反转需要使用三个指针,pre
,cur
和 nxt
,核心思想就是 cur
不断的向后移动过程中,让 cur
指向 pre
。而这一过程分为四步:
nxt = nxt->next
,先让 nxt
向后移动,因为 cur
指向 pre
之后,需要通过 nxt
找到下一个节点cur->next = pre
,实现指针的反转,让 cur
指向上一个指针pre = cur
,为下一次反转做准备,pre
就是在反转中要被指向的节点cur = nxt
,cur
指向下一个节点,为下一次反转做准备通过以上四点,我们可以在推出一些细节:
pre
的初始值应该是 null
,因为任何一个链表的末尾节点应该是空节点,而第一次反转时 cur
指向了 pre
,因此 pre
也就是链表的末尾,因此 pre
初始为空cur
的初始值就是 head
节点,nxt
的初始值也是 head
节点,因为这样才能让 nxt = nxt->next
和 cur->next = pre
有意义nxt
指向 null
,说明此时链表反转完毕,而 cur
指向的就是 nxt
,因此最后要返回 pre
指针1 | class ListNode { |
至于递归方法就简单了很多:
head->next == null
。因此先写出部分程序:如下的程序中,任何一个递归函数返回的都是链表的尾部节点。1 | class Solution { |
1 | class Solution { |
给你单链表的头指针 head
和两个整数 left
和 right
,其中 left <= right
。请你反转从位置 left
到位置 right
的链表节点,返回反转后的链表 。
我决定以后用递归了,如果用迭代去写,涉及的变量和程序都比较繁琐。基于上面的递归反转:和反转全部链表不同,部分反转链表,需要在反转后,将链表的尾部指向原链表不反转部分的下一个元素。之前指向的是 nullptr
,那么这里就需要指向原链表不反转部分的第一个元素。并返回反转链表后的第一个节点。
1 | class Solution { |
给你链表的头节点 head
,每 k
个节点一组进行翻转,请你返回修改后的链表。k
是一个正整数,它的值小于或等于链表的长度。如果节点总数不是 k
的整数倍,那么请将最后剩余的节点保持原有顺序。你不能只是单纯的改变节点内部的值,而是需要实际进行节点交换。
和上一题一样,反转后的链表末尾元素需要指向不需要反转的链表的第一个元素。第一题,反转链表的末尾元素指向 nullptr
,所以需要和 nullptr
判断关系,这里同理,只是不是 nullptr
了。
1 | /** |
主要收录深度优先遍历和宽度优先遍历,深度优先遍历一般可以与回溯、递归、树等方法联用,达到优雅遍历的效果,而宽度优先搜索可以用到最短路问题的求解中。
bfs
去遍历?第一是因为 bfs
写起来麻烦,不如 dfs
直观。第二是在某些查找到满足情况即可退出的应用而言,bfs
需要一层一层的去检查,效率很低。dfs
去求最短路?如上所示,bfs
可以一层一层的检查,相对 dfs
更容易查到最短路。给你一个 m x n
的矩阵 board
,由若干字符 'X'
和 'O'
,找到所有被 'X'
围绕的区域,并将这些区域里所有的 'O'
用 'X'
填充。
O
呢?这里就要用到 dfs
,首先遍历 board
,如果遇到了 O
,那个和这个 O
相邻的 O
也要被填充,此时就要使用 dfs
来查找相邻的 O
X
包围的 O
,因此,边界上的 O
不能被填充。那么我们预先把和边界相连的 O
都填充为其他符号,在处理完 board
内部的 O
的时候,在把其他符号替换为 O
即可。1 | class Solution { |
给定一个二叉树,找出其最小深度。最小深度是从根节点到最近叶子节点的最短路径上的节点数量。说明:叶子节点是指没有子节点的节点。
求二叉树或者多叉树中根节点到叶子节点的最短路径,一般都是 bfs
遍历算法。给出 bfs
的模板:
1 | queue.push(root); |
bfs
之前先处理一些极端的特殊情况,比如根节点为空,根节点就是目标节点等bfs
遍历,如果遍历期间的节点满足目标情况,返回结果即可。1 | /** |
你有一个带有四个圆形拨轮的转盘锁。每个拨轮都有 10 个数字: ‘0’, ‘1’, ‘2’, ‘3’, ‘4’, ‘5’, ‘6’, ‘7’, ‘8’, ‘9’ 。每个拨轮可以自由旋转:例如把 ‘9’ 变为 ‘0’,’0’ 变为 ‘9’ 。每次旋转都只能旋转一个拨轮的一位数字。
锁的初始数字为 ‘0000’ ,一个代表四个拨轮的数字的字符串。列表 deadends
包含了一组死亡数字,一旦拨轮的数字和列表里的任何一个元素相同,这个锁将会被永久锁定,无法再被旋转。
字符串 target
代表可以解锁的数字,你需要给出解锁需要的最小旋转次数,如果无论如何不能解锁,返回 -1 。示例:
1 | 输入:deadends = ["0201","0101","0102","1212","2002"], target = "0202" |
bfs
算法。我们把这个问题看成一个多叉树问题,如果 00
是根节点,那么叶子节点就是 01, 10, 09, 90
,同理,也能得到 0000
为根节点时对应的叶子节点0000
为根节点开始 bfs
算法,我们手写两个函数,分别为 _up
和 _down
来对 0000
的每一位进行转动进而得到子节点,如果子节点满足要求,返回此时的深度即可root->left, root->right
能保证不会遍历重复节点,而对于此问题,很有可能从 0000
查找到 5555
,又从 5555
查找回 0000
,因为只要一直转动下去,0000
也是 5555
的子节点。因此,在遍历期间需要设置一个 map
,将遍历过的节点添加进去,保证不会重复遍历一个节点,不走回头路。1 | class Solution { |
回溯算法是一种暴力枚举的算法。但是,枚举是一个技术活,枚举过程如何保证不重复、剪纸、不遗漏和优先处理可能的结果,这并不简单。
回溯应该是算法系列中除动态规划外最难的一个,需要很好的明确回溯入口,退出条件,两者保证回溯的不遗漏。而下一步如何回溯,以及如何退出当前状态要保证回溯的不重复。也许有些抽象,我们来看具体例子。
如果简单的说回溯,那么如下函数就可以解释清楚:
1 | for (auto i : arr) { |
给你一个有 n
个节点的 有向无环图 DAG
,请你找出所有从节点 0
到节点 n-1
的路径并输出,不要求按特定顺序。graph[i]
是一个从节点 i
可以访问的所有节点的列表(即从节点 i
到节点 graph[i][j]
存在一条有向边)。
1 | 输入:graph = [[1,2],[3],[3],[]] |
如果说从 0 开始,而 0 又指向 1 和 2,那么只能按照这样的顺序找下去,因为题目并没有说要求最短路径。如果遍历的是 0,1,3,那么在找完这条路径后,需要回退,找到 0,1,2 这条路径。也就是,这算是一道回溯类题目。
1 | class Solution { |
给定一个不含重复数字的数组 nums
,返回其所有可能的全排列 。你可以按任意顺序返回答案。示例:
1 | 输入:nums = [1,2,3] |
和上一题大体相同,我们来分析一下不同点在哪里。
[3,2,1]
这样的情况,因此回溯的入口每次都是 0
,而不能是上次回溯的终点,因为 3
后面没有任何东西0
,而且每个元素只能出现一次,因此我们需要使用一个 map
,根据索引记录该元素是否使用,只有没有使用时才能回溯nums
的长度1 | class Solution { |
给定一个可包含重复数字的序列 nums
,按任意顺序返回所有不重复的全排列。示例:
1 | 输入:nums = [1,1,2] |
根据前面提到的求解回溯问题的框架,我们可以注意到这个题有两个细节:
2
可以出现在 1
的前面,因此每次回溯的起点都是 0
。而为了避免添加重复元素,我们需要使用一个 map
,记录哪些元素被添加过,从过跳过已经被添加的元素nums
进行排序,而后先将结果添加到集合内,最后将结果放入 vector
内1 | class Solution { |
给你一个无重复元素的整数数组 candidates
和一个目标整数 target
,找出 candidates
中可以使数字和为目标数 target
的所有不同组合 ,并以列表形式返回。你可以按任意顺序返回这些组合。
candidates
中的同一个数字可以无限制重复被选取 。如果至少一个数字的被选数量不同,则两种组合是不同的。对于给定的输入,保证和为 target
的不同组合数少于 150
个。
1 | 输入:candidates = [2,3,6,7], target = 7 |
需要注意的是,如果回溯的入口是 0
,那么可以得到 2+2+3=7
的结果,在后续的回溯中,如果入口是 1
,而 3
在之前以及的结果中已经使用过了。因此和上一题不一样的是,下一次回溯的入口是上一次回溯的出口,由于一个数值允许多次使用,因此入口和出口的值是可以相等的。
1 | class Solution { |
给定一个候选人编号的集合 candidates
和一个目标数 target
,找出 candidates
中所有可以使数字和为 target
的组合。candidates
中的每个数字在每个组合中只能使用一次。注意:解集不能包含重复的组合。示例 :
1 | 输入: candidates = [10,1,2,7,6,1,5], target = 8, |
1 | class Solution { |
给定两个整数 n
和 k
,返回范围 [1, n]
中所有可能的 k
个数的组合。你可以按任何顺序返回答案。示例:
1 | 输入:n = 4, k = 2 |
[1,2]
后就不会在出现 [2,1]
,因此每次回溯的入口是上次回溯的出口 +1tmp.size()==k
1 | class Solution { |
找出所有相加之和为 n
的 k
个数的组合,且满足下列条件:
示例:
1 | 输入: k = 3, n = 7 |
1 | class Solution { |
给你一个整数数组 nums
,数组中的元素互不相同。返回该数组所有可能的子集(幂集)。解集不能包含重复的子集。你可以按任意顺序返回解集。示例:
1 | 输入:nums = [1,2,3] |
我们发现回溯期间的任何结果都会被返回,即使是一个空集,因此:回溯不在设置退出条件,但此时需要保证回溯的入口条件正确。
1 | class Solution { |
给你一个整数数组 nums
,其中可能包含重复元素,请你返回该数组所有可能的子集(幂集)。解集不能包含重复的子集。返回的解集中,子集可以按任意顺序排列。示例:
1 | 输入:nums = [1,2,2] |
前文已经提到过,如果有重复元素,对于重复元素,先排序,在将结果存储到集合中,最后转化为 vector
1 | class Solution { |
数字 n
代表生成括号的对数,请你设计一个函数,用于能够生成所有可能的并且有效的括号组合。示例:
1 | 输入:n = 3 |
这个和之前的回溯还不太一样,之前的回溯是选择填入一个数字,或者说每次回溯只能选择一个数字,而一般是可以按照顺序选择数字的。但是这个题不同的是,当前的字符取决于前面的字符,如果前面的字符是 (
,那么后面的字符可能是 (
或者 )
,如果前面的字符是 )
,后面的字符可能是 (
或 )
,具体是哪一个还需要判断前面的次数,这样程序的逻辑会很复杂。
也就是说,这种情况,不能用 for
循环遍历 ()
来回溯。既然如此,只能在回溯中进行遍历。
n
,退出(
和 )
1 | class Solution { |
编写一个程序,通过填充空格来解决数独问题。数独的解法需遵循如下规则:
1,...,9
。1 | class Solution { |
按照国际象棋的规则,皇后可以攻击与之处在同一行或同一列或同一斜线上的棋子。n
皇后问题 研究的是如何将 n
个皇后放置在 n×n
的棋盘上,并且使皇后彼此之间不能相互攻击。
给你一个整数 n
,返回所有不同的 n
皇后问题的解决方案。每一种解法包含一个不同的 n
皇后问题 的棋子放置方案,该方案中 Q
和 .
分别代表了皇后和空位。
和数独不一样的是,这个题的遍历状态只有行和列,因为棋子的状态只有 Q
,回溯前放 Q
,回溯后回退为 .
即可。
既然如此,按照常规的想法,就用循环遍历列,当这一列满足要求时,就进入下一行,开始填充下一行的棋子,想到这里,回溯的程序也就可以写出来了。
也就是说对于回溯问题,至少要让回溯中的循环控制一个状态的遍历,而回溯本身则控制其他状态的遍历。
1 | class Solution { |
之前从未意识到位运算的强大威力,认为与或非只存在大一 C 语言的考试或单片机的设计中,直到今天才发现我错了。做一个常用的位运算和数学运算的整理。
与运算我们都知道,1 与 1 为 1,其他的与运算结果都是 0。那这有什么用呢?如果一个数用二进制表示,不断的进行 n = n & (n-1)
运算,可以知道这个数的二进制有多少个 1。
编写一个函数,输入是一个无符号整数(以二进制串的形式),返回其二进制表达式中数字位数为 ‘1’ 的个数
1 | 输入:00000000000000000000000000001011 |
这个题就是上面说的与运算的经典例子,直接写出程序:
1 | class Solution { |
给你一个整数 n
,请你判断该整数是否是 2
的幂次方。如果是,返回 true
;否则,返回 false
。如果存在一个整数 x
使得 $n == 2^x$ ,则认为 n
是 2
的幂次方。
不要急,我们脑部一下 2 的幂那些数,比如 4,8,16 有什么显著特点,答案是他们的二进制只有一个 1,那么就很简单了。利用上一题的结论,直接判断输入的数字二进制中是否有一个 1 即可。
1 | class Solution { |
如果不出意外的话,这个运算只会在考试前一周牢牢记住,考试完彻底忘记。今天来补充一下,异或运算是指:当两数相同时,输出为 false
,不同时,输出为 true
。异或具有以下性质:
其实自反率就是交换率的延伸,而用的最多的也是自反,用于判断数组中只出现一次的元素。只需要异或数组内的全部元素,由于其他元素均重复出现。因此结果只保留只出现一次的元素。
给定一个非空整数数组,除了某个元素只出现一次以外,其余每个元素均出现两次。找出那个只出现了一次的元素。说明:你的算法应该具有线性时间复杂度。 你可以不使用额外空间来实现吗?示例:
1 | 输入: [2,2,1] |
注意,初始化为 0,因为任何数异或 0 都是它自己。
1 | class Solution { |
给定一个包含 [0, n]
中 n
个数的数组 nums
,找出 [0, n]
这个范围内没有出现在数组中的那个数。示例:
1 | 输入:nums = [3,0,1] |
因为数字的范围是 [0,n]
且丢失了其中一个,那么我们直接异或 [0,...,n]
,再去异或输入的数组,结果就是缺失的数字。
1 | class Solution { |
阶乘或数学运算等问题只需要记住一点,不用真的去算,因为结果一定会越界。
给定一个整数 n
,返回 n!
结果中尾随零的数量。示例:
1 | 输入:n = 3 |
我们首先来分析,阶乘的时候只有出现 5 或 5 的倍数时,阶乘才能出现 0。因此,如果阶乘的数字小于 5,可以直接返回 0。而 19 这样的数字能提供 5,10,15 一共 3 个 5,因此末尾 0 的数量就是 3。
此外,对于 25,125 等 5 的幂次方,25,50,75,100 能提供两个 5。也就是说,针对这种情况需要额外的处理,处理完 5 后要处理 25,然后 125,直到遇到 0 结束。
1 | class Solution { |
f(x)
是 x!
末尾是 0
的数量。例如,f(3) = 0
,因为 3! = 6
的末尾没有 0
;而 f(11) = 2
,因为 11!= 39916800
末端有 2
个 0
。给定 k
,找出返回能满足 f(x) = k
的非负整数 x
的数量。示例:
1 | 输入:k = 0 |
分析题意:
1 | class Solution { |
给定整数 n
,返回 所有小于非负整数 n
的质数的数量 。示例:
1 | 输入:n = 10 |
高效的计数素数。我理解的高效是,如果之前计算过,那么后续就不用计算了。如 2 是素数,那么 4,6,8,10 则都不是素数,如果遍历到 7,那么 14,21 也不是素数。
按照想法写出程序:
1 | class Solution { |
你的任务是计算 $a^b$ 对 1337
取模,a
是一个正整数,b
是一个非常大的正整数且会以数组形式给出。示例:
1 | 输入:a = 2, b = [3] |
如果要计算 $4^{1337}$ 次方,直接暴力计算是愚蠢的行为。我们对问题进行分解:$4^{1337} = 4^7 \times 4^{(133)10}$,分治这不就来了。
如果说,之前需要运算 1337 次,那么分治后,只需要运算 7 + 3 + 3 + 1 + 10 + 10 + 10 次,显著降低运算次数,算是一种空间换时间吧。
在补充一条数学运算:(a * b) % c = (a % c) * (b % c) % c
。
1 | class Solution { |
给你一个包含 n
个整数的数组 nums
,判断 nums
中是否存在三个元素 a,b,c
,使得 a + b + c = 0
?请你找出所有和为 0
且不重复的三元组。注意:答案中不可以包含重复的三元组。示例 1:
1 | 输入:nums = [-1,0,1,2,-1,-4] |
在此之前,先来看两数之和。在数组中找到两个数,两数之和为 0。此时我们对数组进行排序,并使用两个指针,左指针从左向右移动,右指针从右向左移动,求两者之和,如果和大于 0,说明右指针指向的数据太大,需要将右指针左移,反之将左指针右移。
1 | vector<vector<int>> twoSumTarget(vector<int>& nums, int target) { |
但是呢,对于 [-3, -3, 1, 2, 3]
这样的数组而言,第一个 -3
计算过之后,第二个 -3
就没必要计算了。因此,可以在数值相等的情况下省略一些情况,前提是数组必须有序。写出以下优化的程序:
1 | vector<vector<int>> twoSumTarget(vector<int>& nums, int target) { |
至此,我们可以写出三数之和:
first
,因为数组是有序的,如果 first>0
的情况就可以跳过first
之后,且另外两数之和需要等于 -first
,此时套用上面的两数之和的程序即可1 | class Solution { |
听到滑动窗口这个词,让我想起了计算机网络中的 TCP 传输和拥塞控制,可惜时隔多年还给老师了,那老师讲课还很不错。我的大部分本科老师都有着很多年的工作经验,并不像部分硕博留校那种只擅长验证玩具理论和咬文嚼字,而从未到实际环境中实习过一次。所以他们讲课十分形象具体,结合理论和实际环境告诉你这是个什么东西。
说远了,回到正题。滑动窗口一般用于求接子串中满足某种情况的最值,可以简单的划分为三类:
这个是最简单的一个,也是后面其他滑动窗口的模板。既然是滑动窗口,就必然有一个窗口和滑动的步骤。为了实现窗口,我们定义两个指针,左指针和右指针。随着右指针的移动,窗口逐渐变大,也就是窗口扩张。如果破坏了和满足了题目要求,那么左指针移动,称为窗口收缩,根据题目条件判断窗口收缩的程度,然后当前窗口就是一个满足题意的结果。
随着左右指针的先后移动,会以此查找子串中所有满足情况的子串,我们保留其中的最值即可。说了这么多,来看一个例题,不然太抽象了。
给定一个字符串 s ,请你找出其中不含有重复字符的 最长子串 的长度。示例 1:
1 | 输入: s = "abcabcbb" |
我们来分析一下,条件就是不含重复子串,结果就是保留最短的子串长度。既然如此,大概思想就是:
我们写出程序:
1 | class Solution { |
给你两个字符串 s1
和 s2
,写一个函数来判断 s2
是否包含 s1
的排列。如果是,返回 true
;否则,返回 false
。换句话说,s1
的排列之一是 s2
的 子串。如 adc
是 kajihscda
的子串。
同样,窗口滑动的时候,把条件判断更改为是否能覆盖 s1
即可:
s1
,收缩窗口,直到不能覆盖 s1
s2
中的窗口大小s1
的排列组合甚至可以看到,这个和最开始的「无重复字符的最长子串」如出一辙,我们写出程序:
1 | class Solution { |
给定两个字符串 s 和 p,找到 s 中所有 p 的 异位词 的子串,返回这些子串的起始索引。不考虑答案输出的顺序。异位词 指由相同字母重排列形成的字符串(包括相同的字符串)。
1 | 输入: s = "cbaebabacd", p = "abc" |
和上一题一模一样,写出程序保存所有结果即可:
1 | class Solution { |
给你一个字符串 s 、一个字符串 t 。返回 s 中涵盖 t 所有字符的最小子串。如果 s 中不存在涵盖 t 所有字符的子串,则返回空字符串 “” 。示例:
1 | 输入:s = "ADOBECODEBANC", t = "ABC" |
仿佛不知道说啥,就把上一题的异位词,改成每次保留最短的子串就好了。如出一辙:
1 | class Solution { |
给你一个整数数组 nums
,有一个大小为 k
的滑动窗口从数组的最左侧移动到数组的最右侧。你只可以看到在滑动窗口内的 k
个数字。滑动窗口每次只向右移动一位。返回滑动窗口中的最大值 。示例:
1 | 输入:nums = [1,3,-1,-3,5,3,6,7], k = 3 |
说实话,这是我见过最好的一个数据结构设计类题目。如果每次滑动都遍历求解最大值,是最简单的方法,也是超时的做法,此时就要发挥算法的魅力了:
1 | class myque{ |
开头提到讲课的老师,我还是很怀念我大学工程能力很强的老师。