0%

Computer Science Tips

记录一些小知识点,供个人搜索使用。反正脑内记忆也会忘。

Nan

真的float/double类型当然不会有nan,它就是个数字?但print float/double类型的时候,可能打印nan,它既不是最大值也不是最小值。C++中,构造如下:

1
2
3
4
5
6
7
8
9
#include <iostream>   // std::cout
#include <string> // std::string, std::to_string

int main ()
{
double d = 0.0 / 0.0;
std::cout << d << " " << std::to_string(d) << " " << std::numeric_limits<double>::max() + 1e308 << " " << 1.7e308 + 1e308 << std::endl;
return 0;
}

应该只有/0的非法值能打印出nan吧,加大值再加一点溢出应该只会得到inf而不是nan。但它具体是个什么数字?查c++和java的文档,可以知道它是超出double正常值理解的一个值,https://en.cppreference.com/w/cpp/numeric/math/nan ,打印出其hex值,为7ff0000000000000。根据标准来读https://en.wikipedia.org/wiki/Double-precision_floating-point_format,可以使用转换工具,更容易懂,https://gregstoll.com/~gregstoll/floattohex/ ,1位S符号位是0正数,跟着11位作为指数E(Exponent)全1,10进制是2047,这个数定义是special的,后面52位都是fraction,记为F,F以1开头,不为0。

Alt text

按照上图的说明来判断,当E是全1时,会被拿来当作特殊数字,inf和nan,具体是谁,看F是否为0。也就是inf,nan虽然特别,但都能存在double变量里,只是读取时遵循规则,可以提取出这两个特别的数。

但很离谱的是,rapidjson 1.0.2版里,它是不管的,不会判定nan/inf,直接当作数字来打印,当然就会得到很大的值,超过double合理范围的。inf还好说,inf打印出的数字,基本上能被各种语言的json库读出来,但nan打印出的更大的数字,json read时就会被当作inf了。所以,rapidjson 1.0.2版,是无法正确读取nan的。但rapidjson 1.1.0是可以的,还有多种读写规则。

Union

union在json里还有妙用,印象中union是被拿来节约内存的,也知道如何计算占用内存,包括看最长的和padding。但其实没真正利用过它,不知道它的优雅用法,甚至有时候会误以为它是把几个变量用更紧凑的方式放在一起,好像是把union和struct记混了,因为struct考题会考到padding。注意区别,union是共用,struct是组合。

单说union在json中的应用,因为json的number数值实际可以是好多种,int,double,读的时候还要支持无符号位整数,但作为一个对象,它当然只会一种。给这个类整多个数据类型的成员,分别来存,使用时却只使用一个,其他全浪费,当然不如union存一块区域。所以,类似rapidjson就是用union来表示数字,其实无论数字大还是小,都存在一块内存里,读的时候再具体选union中的某个,实际就是在选不同的指针。

Useless Knowledge

先叠甲,不是知识无用,只是对我来说用不上。纯当课外读物了解着玩,包括一些朋友对我作为计算机专业人士的神奇提问,和我觉得很离谱的新闻,我就想看看它是不是真的。

PWM 脉冲宽度调制

Pulse width modulation,https://en.wikipedia.org/wiki/Pulse-width_modulation

突然想到了大学实验课上的呼吸灯实验,联想起来自己还是不太理解当时为什么要那么写代码,所以这次来深度了解一下。当时只是知道代码写法,并不理解原理,现在才查到是PWM。

首先,PWM并不唯一,它可以用在很多地方,它本身的实现也很多,呼吸灯就是个简单实验而已。

模拟电路的缺点不多说了,总之是希望用数字电路来顶替模拟电路,但是数字电路的输出是高电平或低电平,而模拟电路的输出是连续的,可以是高低电平范围内的任何一个值,PWM就是来解决这个取值问题的。

硬件,比如LED灯,本来就是可以接收一个范围的电压,并给与相应的亮度,当然,此处还是理想地说,电压是稳定的一个值,LED就在那里亮着。如果LED接受的电压不是稳定的,是脉冲呢?假设脉冲很慢,1s高,1s低,我们就能看到LED在闪烁,但是如果脉冲很快,比如10ms高,10ms低,我们就看到LED是亮的,但是亮度不是100%,而是50%(10ms仅仅是个假设,我不知道具体什么频率才能让人眼看到,频率过快应该跟常亮没有区别)。100Hz应该就看不到闪烁了。这就是PWM呼吸灯的基础,PWM调光,PWM Dimming。

有了Dimming,我们可以在LED上实现各种亮度,比如50%,75%等等,只要每一小段时间变换亮度,就能实现呼吸灯。

那么,PWM Dimming具体什么样子?

可以想象,如果当前给一条高电平直线,我们看到的是常亮;如果直线中每个1s只出现1ms的低电平,那个时候LED肯定是熄灭的,但持续低电平时间太短了,人眼根本无法察觉,而且LED还可能有余晖,不会瞬间黑了,就更加容易掩盖熄灭的时间,也就是视觉残留。

但为何视觉残留,就能完美模拟出0-100%的亮度呢?我们为什么不能从50%空占比(Duty Cycle)中捕捉到那么一瞬的高于50%的亮度?这个从经验上有人总结过,就是说,人眼看到的是一种平均值,而不是瞬间的值,所以,如果LED的空占比是50%,那么人眼看到的就是50%的亮度,而不是瞬间的100%亮度。这个叫做Talbot-Plateau Law,中文名不统一,可能叫塔尔博特-普拉托定律。它是由实验支撑的,就是下图的黑白盘,又称扇形实验。

圆盘

圆盘的一个环和PWM调光的某个亮度是类似的,转起来后,我们人眼就是看到了一个平均值,而不是瞬间的值。背后的生物理论还不了解,但这个实验还是能让人理解,我们人眼确实是被这么骗了。

我们将这个定律作为前提条件,相信它,那么,可以得到下图所示的结论,我如果恒定频率,将会得到不同亮度。

PWM调光

顺带一提,PWM虽然骗过人眼感受,但实际还是让我们的眼部细胞被迫处理瞬时的变化。所以有些PWM频率过低,会让人眼睛类,甚至头疼。说起来,有些手机就是被人吐槽PWM频率过低,瞎眼睛。不知道为何,它们不适用高频,可能和功耗有关?

PWM调光,应该可以代码模拟一下。不知道电脑显示器的刷新频率是否会拖后腿,但先尝试一下吧。学校时期还真有单片机、arduino一类的开发板,和LED灯,目前就只能电脑模拟一下了。

首先看看能不能一步到位,Python中使用画图的工具,最终要能达到做动画的效果,限制为只使用某个亮度,那么它就作为本实验的100%亮度。比如matplotlib就可以做类似事情,而展示图片wsl也适配了,可以弹出图片框。但测试了一下频率,做不到人眼感受不到闪烁的程度,人眼还是牛啊。

那就只能做PWM波形了,这个用matplotlib画图,并不断更新图片就可以。假设这个生成程序可以用于控制LED硬件,它应该是可以设置频率和占空比,然后持续生成电平值的。我们就以这个角度来写代码,再找个方法做图片。

https://www.electronicwings.com/raspberry-pi/raspberry-pi-pwm-generation-using-python-and-c

但其实真正的硬件控制,是有些高度封装的。但为了好画图,我还是当作是set value的,而不是set frequency的。并且是持续输出,最大频率先定一个,一段时间为高电平,体现为很多个1。得到代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import matplotlib.pyplot as plt

resolution = 1000 # Hz
# 1 / resolution = 1ms 1 value in the list is 1ms

def pwm(freq, duty_cycle):
# gen a list of 0 and 1
period = int(1 / freq * resolution)
on = int(period * duty_cycle)
off = int(period * (1 - duty_cycle))
print(on, off)
while True:
for i in range(on):
yield 1
for i in range(off):
yield 0


data = []
for i in range(30, 100, 10):
gen = pwm(10, i / 100)
data += [next(gen) for j in range(1000)]
for i in range(100, 30, -10):
gen = pwm(10, i / 100)
data += [next(gen) for j in range(1000)]
# plot
xs = [x for x in range(len(data))]
print(len(data))
plt.plot(xs, data)
plt.show()
plt.close()

芯片制程

学校中曾经了解过一点皮毛,但我的速度已经跟不上2023年前后的芯片厂们了,怎么能2nm都出来,它不会失效么?而这一问题,还真有人问过我,那时我只记得它们是虚标,工艺是在进步但废片率也高,所以虽然说得很先进,但目前还不会变成产品到我们手上。

突然想起这事,想要了解多一点细节,比如哪里假了,为什么虽然假,还是可以这么命名。搜到很多营销号千篇一律的话,完全没有解释,只是说了一些芯片制程的历史,然后就说了一句,这是虚标。知乎上的回答才稍微找到一点靠谱的,我也读的不深入,此处也不引用了,免得我的错误解读曲解了知乎回答。

我就大致写下我想要了解的问题解释,不加太细节的名词,以免误导。

首先,最早的芯片是planar的,要往里塞更多单元,当然是把单元做的越来越小,这样就能让单位面积里密度越来越高。但当初并没有用密度来衡量芯片,而是用了单元的尺寸来描述。可能从硬件制作来讲,如何做出一个小的单元,才是工艺的最直接体现吧。造单元的时候,衡量它用密度也很奇怪。但进一步的,也就用这个尺寸来描述芯片了,我们并没去在意芯片有多大,有多少个单元,密度多少,而是用单元的尺寸来说这个芯片有多强。这里就是误解的第一步了。即使还是planar,制程的Xnm(应该一直是Gate Length吧,不知道有没有改变过指标,还需要订正,本文只做虚指)已经不代表单元的尺寸了,因为它比单元尺寸缩减地快。可能因为单元尺寸、制程尺寸都是在缩减,正相关。Gate好像跟耗电量也有关,功耗也是一个很重要的点。反正一通下来,可能没找着更合适的,就没有换。

第二步,设计革新,芯片不再是planar了,它变造型了,还开始立体叠甲了。从设计造型改变后,Gate尺寸大概可以理解为能直接缩的很小很小,这时候还用它当指标,当然就很明显不对了。你说你电脑超薄,结果是电脑的最边缘超薄(joke)。那么,它就开始是理论指标了。比如,新的芯片密度是随便什么面积单位下有100M个单元,那么它的制程就是换算成planar,如果planar设计下要5nm才能塞下这么多单元,我就叫5nm制程的芯片。根据好几个人的说法,是2012年22nm后开始无法标志实际尺寸,而是只能做等效计算的。

但既然都是等效制程了,为什么又出现了英特尔和吹水厂们两派?这个地方我还不确定,有空读一下图做一下计算。目前看来,是英特尔完全遵循等效计算法,但其他吹水厂又换了个玩法,就是摩尔定律坐标图。它们的下一代就一定是摩尔定律的下一个点,于是,16nm是某年的一代,我下一代进步了,只进步了一点点,但我一定要叫下一代的名字,也就是12nm/10nm。(摩尔是翻倍,那么芯片二维来看,面积变成上一代的0.5倍,等于边长变为上一代的0.7),所以16*0.7=11.2nm,反正都是虚假的了,向上取整向下取整随便来。这一招6啊,没有人比他们更懂摩尔。

这故事告诉我们营销的重要性,我身边的人不知道有多少人被这些名词忽悠了,还说某厂掌握核心科技,远超国外。太猛了,我解释了,但我感觉也并没有改变他的观点,因为他已经信了。

希望我能有一天负责任地夸奖国产芯片,主要是说热门的那些。(没有说全都不好的意思)

大概只有自己能看懂的手绘架构图

处理分布式系统好几年了,发现很多使用者最大的障碍是不懂每个组件在干什么。有的人把每个组件都想的很重要,每做一步操作都要询问是否危险,有的人把每个组件都想的很简单,每做一步操作都不会考虑后果。这两种极端都不好,所以我想通过这篇文章,把我对一些现有的分布式系统的理解分享给大家。

这些结构通常不会在官方文档中展示,因为它有点深入系统,但又没有特别深入。按理说,设计文档里应该有这样的图可以参考,但大多数系统都没有提供设计文档等细节,好像都“藏了起来”。可能又是知识的诅咒吧,会的人觉得都理解了,画图干什么,不会的人也各种原因无法抽时间看代码,参考图都是抽象的不行,不懂就永远不懂。

OpenMLDB

五个server组件,分别是:ZooKeeper(以下简称zk),NameServer,TabletServer,TaskManager,APIServer。加上客户端,一共六个组件。我们先不管APIServer,无状态,只是特别的CLI而已,并不需要特别介绍;也不管TaskManager,它也是无状态,管理起来很简单。

架构上,我们先不管它们之间到底要做些什么,交流的具体内容是什么,只看它们之间是否会产生连接,就有下面这张图。

server之间的联系这里画的不多,不要认为它们不联系。上图主要是用户的视角,可以看到几个明显特点。

  • CLI跟所有server(包括未画出来的tm和api)都有直连,区别于某些系统,它们有frontend,所有请求都要经过它。
  • CLI还会跟zk联系,其实很容易想到,毕竟openmldb地址就是zk地址。一切跟集群的联系都是从zk获取某些信息开始的。
  • table是分散放入tablet server的,每个分片还是多副本。副本协议暂不管。

从运维角度讲,用户需要保证:

  • 组件都活着(当然,少部分掉线可能没关系,分布式的核心优势,但最终都应该恢复,掉线就靠近危险边缘了,不能就这么放着)
  • tablet server上面存着数据,没有一个tablet server置身事外,所以,tablet server的storage要是正常的。tablet活着,storage不正常,该tablet还是无法服务,跟下线没太大区别。

而tablet server在意外或非意外的重启后,恢复数据是需要时间的,还有可能失败,但并不像server online/offline那么明显,很多用户会不知道有server处于正在恢复,或已经失败(但server仍然在线),直到某次使用时才发现使用出错。这就是一个常见的坑,很多分布式系统都可能发生,不过openmldb很容易出这个错,现在正在改进提示和运维方式。

用户基本不会去关心除了节点是否活着之外的其他事情,甚至节点活没活也不关心。运维角色在很多现实场景下是缺失的,也不能期望用户每次请求前都看下集群是否正常,所以,正规的话应该有“运维角色”,有报警机制,确保集群正常工作,不正常时会告知业务。如果运维不完善,就只能让用户更加容易获取集群状态,也就是事后的补救了。

SQL查询

组件在具体某个操作,某种请求下,各自扮演的不同角色,这不是组件架构图能体现的。所以,我们再画一张SQL查询的路线图。

类似impala,每个server都可以是一次SQL的主要负责人。当tablet1需要别的tablet上的数据时,是通过subquery找别的tablet查询的,走过engine去storage获取。也就是一个sql会被拆成多个task,有些事情可以在别的tablet上做,就在那边处理好了再返回。可以想象一种最简单的情况,直接从别的tablet获取原始的数据,然后,负责的tablet1里做全部的计算。这个要看SQL计划和SQL Engine的优化,具体不详谈了。

admin

Admin操作,例如增删表,增删deployment等等,也不是单点的,毕竟这是个多组件的集群。通常的情况是,CLI发送操作给nameserver,nameserver如有必要,会发送命令给所有tablet server,成功后再在自己本地操作,还可能写操作log到zk。

可以看到,参与者很多,而且各处都可能有元数据,有些操作又是异步的,某一个点失败了也可能无法回滚。所以,有时候还会发现元数据匹配不上,分布式的痛,只能具体问题具体分析了。

写在前面

这一篇文,记录下我的一些不成熟的心得体会。希望当我懈怠时,能稍微提醒下自己。(虽然懈怠是常态)当我鼓起勇气想要努点力时,能让偏航的自己稍微回归下航道。

学习

当被工作占据了时间和精力后,学习就变得很难。即使是不加班的工作,下班后也是想着要放松。曾经不太理解很多人说的,在学校时要专注学习,有这么多时间的时候不多了。现在是真的理解了。

有那么一些时间阶段,我可以腾出玩耍的时间来学习时,也会突然发现,我不知道该学什么。好像有很多都值得学,但似乎看不到学成后的收益,甚至学了就忘了。这时候,肯定有心灵鸡汤端上来了,说“不要用功利心来对待学习”。但我认为,这多少有点假大空了。当我想要通过学习,来改变自己的工作状态(可以是学习新知识,让自己可以承担新任务,也可以是让自己在现在的工作中做得更好),我凭什么不功利呢?站在这个地方,我就是希望自己能进步,那我当然是要学跟自身工作领域相关的东西。我学习了半天,宽度没拓展,深度也没增加,那我在干啥?当然,努力了,进步了,不代表现实会回报我。但是,是在原地踏步,还是真的进步了,自己是能知道的。

我们还是需要意识到,现在的世界已经过于复杂了,就单个细分的计算机领域,也已经是一个庞大的体系了。然后,还有无数的书籍,无数的培训课程,无数的学习区博主,我们不通过自己来分辨好坏,来评估收益,怎么学的完?

在我当了几次不成熟的面试官时,也听到了几次类似的说法,我们在学校时也流行这样的说法:什么语言不重要,要学好基础,掌握内核。事实上,内核有几个人能掌握?语言倒是很明显没有掌握。

从我的自身经验来看,计算机的学习中,尤其是我们是面向工程,而非学术,很难只限于理论。一定量地积累后,语言方面不可能什么都不知道。自己写demo、刷题,会不用一个顺手的语言,不碰几个bug么?学习软件工程,设计模式时,会不知道语言能提供的特性么?我不会孔乙己式地考察茴有几种写法,我经历过的面试中,这样的也是少数。或许我们没有那么“八股”。

假大空确实是给学生时的我们好好洗了一遍脑。幸运的是,有的同学确实有着不错的基础和领悟能力,在工作中,面对挑战,也能通过自身努力去解决,然后又从中学习,不断进化;不幸的是,有的同学还没醒过来,不知道自己缺少了什么,在工作中表现得非常不专业。

所以,我想我们需要“功利心”的学习。明确自己缺了什么,非常重要。学校中努力地学习,它的范围是很明确的,有教材有老师,结果也是很明确的,有课程的成绩。但在工作中,一切都是那么不明显,工作都很难量化,更何况对一个工作的人的能力评判。

那么,我自己有什么缺陷呢?商业机密(开玩笑)。自己当然不会觉得自己某个方面是很好的,毕竟我也是时代特色教育下长大的,不自信是刻入DNA的。目前,我希望我能在宽度上多积累一点。从自身的工作中,体会过几次,新任务中使用曾经了解或者开发过的东西,让我觉得,我如果能了解的更多,就像手中有更多的钥匙,或许未来又能用上吧。

世界纷纷扰扰,能够不被打扰,专注自己的人,我十分佩服,这样的人很像修道者了。愿我能成为“修道者”吧,可以肯定自己,信任自己,和自己和解。

英语

Tough right? 大学以前的英语学习就经常全程问号,也不知道自己怎么过来的,每次英语做对的题我也不知道是怎么回事。听力其实成绩不错,但说实话,听不懂,理解或记录一句话时,听力音频已经blabla好几句了。但大概能抓住重点,做题是没问题了。而选择题之类的,词组更是记不住,看谁念起来通顺就是它了。那时候,觉得英语想分数变高,不是去学日常英语,而是去感受考试题目,提升语感。但毕竟科目众多,不可能花太多时间在英语上,所以,经常是多分精力给英语时,成绩就涨,一旦分更多时间给理科,英语就又回去了。最终,我还是选择了让英语回去,毕竟就科目而言,它涨分的效率是最低的。

四级就不谈了,躺着也能过。大学的六级难度还是加高了一截,能过,但听力分数不够高,而当时我正需要听力分数。于是,我又开始了强化听力练习,选择题、作文是一点精力没花。那段时间对于应试还是很充足的,不像工作后精力时间都有限,而且目的也是应试,并不是为了真实口语水平。但在有限时间里想提高,还是到处收集了一些方法,毕竟我也不知道学到什么地步足够,只能尽力提升。方法是真的五花八门,谁都说得很有道理,但坚持才是最重要的。可时间总是有限的,想要效率最高的方法,我认为并不是错误的。

这里又吐槽一句,经常在谈起学英语,学画画,学钢琴的时候,很多懂哥就会出现。“不打好基础怎么提升?”“你不苦过,怎么甘来?”每句话单说都是对的,可前提呢?提问的人想要知道速成,或者说半成(不需要到什么专业水平,只是想让自己能快速获得一定的结果),他们是想获得结果、获得快乐或成就,不是要拿它当饭碗,你却来说一句,要从三岁开始学起。这不是在搞笑吗?我还能倒退回去不成。可成人又不是不能学,不可以学成。我不是很理解,在成人探讨学习路线时,跑来泼冷水的心态。就像是要维护自己的护城河一样,感觉别人半路出家,就是对自己的侮辱。事实上,护城河只有两个,真实实力和学历,你可以实力不强,但学历高,也会加分,你可以学历不高,但你得让人看到实力。你不能阻止别人增长实力,甚至超过科班,一切只在于那个人自身,请牢记“天外有天”。我希望,在做尝试的人不要被这些人打击到。当然,中间更有不少卖教材、卖课程的,通过夸大难度,强调你应该买课程,应该找外教。想要从那么多的方法里快速找到适合自己的,是真的不容易。

回想当时,我那时候考虑过,是不是还是从语感入手,就是通常所说的磨耳朵。可是,这个耳朵磨到起多少茧才会有用?这是无法衡量的东西,你听懂了Hey,What’s up,How you doing,这不代表你能听懂考试听力。仔细对比听力文本和日常用语,也就能发现,它们各个方面都不一样。考试听力并不快,也不会讲俚语,所以特点是,说得不快,但信息量更大。非母语的我们,消化一句日常用语,不会很难,甚至听个50%就基本理解了,但消化一句听力的时间很长,会感觉听力特别快,感觉自己忙不过来。事实上,单看语速,每分钟讲了几个单词,它并不快。所以,那时候我选择硬嗑真题听力,因为我需要习惯,从听力里提取出有用信息拿来做题。要习惯从这样的语速中,获取一些不太直观的信息。不断地听同一段听力,慢速、正常速,真的很折磨我,主要是我是非常不喜欢重复的人。但所幸考试这个动力还是足的,我还是坚持了下来,最后的听力成绩超过我的预期,也算证明我的选择是对的吧?

毕业后,英语对我来说就是看计算机知识、项目的工具了,但这样,它就几乎只有读写了,于是,我的听力就。。。poor thing。这个时候,我想提升英语,就不再为考试服务了,可以倾向于听说。听方面有非常多的方法,也可以不再枯燥地学习教材,很多人会推荐美剧、动画等等。但难过的是,我不是美系爱好者,就算我喜欢,还是会有阻碍。我不喜欢重复,我不喜欢看我已经知道结局的东西,可以说是出厂天赋点歪了,这些升级办法对我来说并不是轻松学习而是痛苦。从我以前看日文动画的结果来看,即使每部作品只看一遍,也会变得能做到日常听说,但并不爱好美剧,也导致我甚至没有什么感兴趣的素材作品,很难积累到质变。我也实际测试过,掏出我为数不多喜欢的英语作品,我也并没有坚持下去,实在不感兴趣了。这就是兴趣的重要性吧,实名羡慕拥有英语兴趣的人。

现在的普遍观念好像是改变了一下,以前会有学生家长问我怎么学英语,但他们的关注点是怎么提高分数,而现在感觉问的人重点都是如何培养英语兴趣。他们的眼光似乎都更长远了,当然,只是我身边的变化。甚至还问看美剧有没有用,放在我读书时,这是不好好学习,大逆不道吧。然后我都直接推荐《小猪佩奇》,只能从学校学英语,能把它听懂的,听力都是很好了。现在的家长对英语更加重视了,手机电脑网络更加普及,家长也不再特别敌视网络,如今的学生可以接触更多样化的英语输入了。对比之下,我很难想明白,当初我们如何学习的,那些英语成绩奇好的但也非从小学习的同学是多么的天赋异禀。没有忽视他们努力的意思,毕竟大家都只有这几年的学习时间,当时信息又很闭塞,没有多的捷径,能把英语学好,其他学科也样样不落的,真的很厉害。

但《小猪佩奇》也有一点问题,就像是看美剧学英语,记住的只有骂人的话一样,看佩奇真的感觉只能记住那几声猪叫。我个人感觉比较明显,它存在感太强了。所以,以后我可能会推荐别的吧,目前候选者是《咱们裸熊》。对家长三大宝具:英语看动画片,学好数学,大学前编程学Python。

听力素材

扯东扯西了不少,下面说点有用的吧。想提升日常听说,当然是要听日常的语音。最完美的教材应该还是真人的对话,并且有画面,很多话也要看一个气氛,缺少画面听英语会难很多。如果这一片找不到你感兴趣的教材,就只能找无画面的音频了。podcast里难度低一点可以尝试ESLpod教材(教材,不是它的所有音频,那太多了),难一点的不用我说了,自行随便找。最难的是选择一个不难的。

ESLpod教材是有文本的,听它的课里,实际也会教单词如何拼写。其实不用那么敌视学校学习的方法,音标、语法和单词并不是学不好英语的原因,学习它们肯定是有利的。谁能说音标对自己听力无用?谁又能说语法对自己理解语句无用?当然,英语考试是不够合理的,死背单词是会忘记的。错在于僵硬,而不是这些知识本身。假设背过的单词不会忘,谁能说背单词不是英语学习的最佳方法呢?也不用那么敌视哑巴英语,能听懂再说又不是不行。先要求环境这样那样,结果要求的环境达到了,也不一定就学好了,差生文具多。只要你感觉到了听越来越轻松了,哪怕就是同一段内容,那也是往前走了一步,永远比停在原地好。当然,如果你不满意于这样的效率,可以再找找别的方法。

一些通用的大数据、分布式等方面的算法总结。单独出一篇文章,因为这类算法都是通用的,但具体到各个系统,实现方式可能不同,本篇只总结算法思想,可能伪代码实现最基础的算法,必要时会给出现实系统的参考链接。

一致性哈希

哈希可以说是出现在大数据的方方面面了。一到切分,基本都是它。一致性哈希,先说哈希,也就是虚拟哈希空间,有2^32个片,叫哈希环。

然后哪个服务器负责处理哪些哈希片,是靠映射。具体方法没有规定,比如,可以用服务器ip来hash,还是2^32取模,得到的数字,它就处理哈希环上对应的片。数据来了先hash取模,得到的值然后顺时针找可以服务的服务器,就发给它。

chash

如果中间某个服务器down了,反正算法是往后找到第一个服务器,它还是会被处理。当然这个就要求“数据可以被任何server处理”,不能是特别的。比如,相关数据只在某个节点,其他节点获取不到,处理条件都不满足。当然这是理想条件,现实很多也不满足,但它们涉及到的数据也可以是多节点都存有备份,但又不至于所有节点都需要。这个也等于缩容,受影响的数据是“会落到这一下线节点的数据”,我们称为nodeB,将前一个节点nodeA的末尾片记为end1,当前节点末尾片为end2,也就是(end1, end2]区间的数据原本应该找这个节点B,但它下线了,就会变成去找下一个节点nodeC。其他片上的数据不会受到影响。虚拟哈希环不会改变大小,永远的2^32片。如下图左边所示。

而扩容就在hash环上加一个node,原本[start, end]会找nodeD,你在nodeD前面塞了个nodeE,那么[start, nodeE_end]这部分数据就是受影响的数据,原来要找nodeD,现在变成nodeE。如下图右边所示。

scale

优点不难猜测,节点上下线只会影响局部数据,这已经是很大的优点了。不能傻到还在N个节点,就hash(req)%N吧,增加节点你还需要把N改成N+1。而且它会改变很多请求的路由,缓存都不无法利用。(当然,要是系统就是这样的简单,啥也不要求,那也不用考虑改变了,简单就是最好的。)

但这还有优化空间,因为在前面所说的算法中,nodeX总是要分到连续的多片,现实中是很难保证每片都差不多热度,也就更不能要求每个node的请求都是均匀的。而且节点数量改变时,在现实条件下,上下节点是要改变很多东西的,可能你要将一些数据复制到节点中才能正常服务,也叫做Node rebuilding。它是一个很重的操作。

为了解决,又引入“虚拟节点机制”,就是hash环上不是物理server node,而是每个物理server都膨胀出,node0-0、node0-1等等的虚拟节点,这样虚拟节点打散分布在hash环上,倾斜可能性就小些。不再是一个node去处理连续的一大片区域。如下图所示。

vnodes

向图中展示的一样,一个server node处理离散的几个片,那么每个server的负载均衡就很好操作了,比如,我可以让1个server服务3个高热度的,其他server负载10个低热度的。不用费心去在环上取一个很好的位置,才能把连续的几片服务好,有变化时更是运维难度大。而迁移片也是只对这么一个小区域,粒度更细,rebuild压力也就越小。

也有人说Vnodes负载均衡的情况还可能是让性能好的机器服务更多、更热的片,小机器就混一混。机器规格不一致的时候,确实是个好处。不过我实际在工作中挺难看到不一样的机型在同一个集群。可能现在机器越来越便宜了吧,以前还是很看重成本的,甚至看到机器闲置会进行降级。

整个算法知识都可以看文章 https://interviewnoodle.com/how-to-use-consistent-hashing-in-a-system-design-interview-b738be3a1ae3
,讲的很细,图都是引用的它的。

现在来讲讲如何代码实现一致性哈希。

简版

简单版本,我们只做哈希环,然后node负责连续分片。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def hash(key):
return hash(key) % 2^32

map<hash_value, node> ring
# map key可以不是范围,而是end分片,这样可以用lower bound来找到第一个大于等于req_key的node
# 但原生Python不支持,得新建class
# Java ceilingEntry和C++ std都可以直接用

def get_node(key):
hash_value = hash(key)
return ring[lower_bound(hash_value)]

# main
req_key = hash(req)
node = get_node(req_key)
node.handle(req)

如果存在一群相同元素,那么 lower_bound 和 upper_bound 就可以找到这群元素的上下界限,前者指向下界限,用 lower 表示,后者指向上界限的后一个位置,用 upper 来表示。也就是说,lb是大于等于key的第一个元素,ub是大于key的第一个元素。

开源实现

Guava consistentHash

Guava库中的com.google.common.hash.Hashing.consistentHash就实现一致性哈希算法。一般使用方法是,你有一个key,用Hashing中提供的某种hash算法来hash这个key(比自行提供hashCode会好,提供了murmur之类的算法),得到的HashCode去调用consistentHash方法,再加上一个bucket数据限定,就可以得到一个bucket的index,也就是说,你的key会被分到这个bucket里。

但仔细看它的consistentHash函数,它压根儿没做一致性哈希理论的那些事,完全没有哈希虚拟空间的事儿。

它的理论是源于此文章 https://dl.acm.org/doi/10.1145/258533.258660 ,一致性哈希的根本定义是“对所有的 Buckets 和 Items 应用相同的哈希函数 H,按照哈希值的顺序把它们排列到一条线上,然后将距离 Bucket 最近的 Items 都放入该 Bucket 中”,哈希环是一种实现方式,但不是唯一的实现方式。

Guava一致性哈希算法实现者的文章,https://arxiv.org/ftp/arxiv/papers/1406/1406.2294.pdf ,批斗了一波原版,提出了新的“jump consistent hash”,不用存server/bucket服务哪些哈希片,速度还快。但它是有条件限制的,buckets必须是连续,否则这个index刚好是下线了的node id,那肯定不行。如果要用这一套,必然是得把bucket抽象化,也就是Vnodes那一套。

先只看JumpConsistentHash。首先,先抛弃一致性哈希的经典哈希空间,抛弃空间与真实bucket的对应关系,我们只去想这个算法要干什么,就是想将input放进bucket里,想均匀地放,当然也不能乱放。不乱放是指,key=k1,那么k1在bucket数目恒定时,就应该永远被放入同一个bucket,不然就是随意乱放了,random放,更甚者循环放就行了。

规律总结就是,记ch(key, bucket_size)为一致性哈希函数,那么得有:

  • ch(k, 1)=0,即所有key都放bucket0,毕竟总数只有1个bucket
  • In order for the consistent hash function to balanced, ch(k, 2) will have to stay at 0 for half the keys, k, while it will have to jump to 1 for the other half. In general, ch(k, n+1) has to stay the same as ch(k, n) for n/(n+1) of the keys, and jump to n for the other 1/(n+1) of the keys.
    ch(k,2),就是分一半流量给新增的bucket1。进一步的,ch(k, n+1)对比ch(k, n)则是,n/(n+1)的key完全不变化,1/(n+1)的key,它们的bucket变更成n。(必须n/(n+1)部分是完全不变化,bucket值都不可改变,不然就算不得一致性哈希了,就是随便乱来算法。)而既然是最后还是要key平均进入bucket,当然是得每个bucket里抽出一部分来,放进新bucket。

这样,就可以用ch(k,n)来描述ch(k,n+1)了。也就是说,ch(k,n+1)计算时,都是先用ch(k,n)计算出来,但有一部分key应该考虑分给新bucketn。从一个key的角度来看,它就是在不断jump,它在ch(k,1)时,就是0,然后在ch(k,2)时,就是0或1,满足条件,它就应该到1那儿去,然后在ch(k,3)时,它又该考虑是留在bucket1还是跳到bucket2,以此类推。还真是名副其实的jump。

jump

图中,从左下框架上来看,只有?函数安排合理,就能得到一个非常简单的一致性哈希算法实现。那么,关键又变成了什么样的函数才能做到。假设k2是输入key,返回值是恒定的0.1,那么2个桶,它就会被判定去桶1,3个桶就去桶2。但这么就搞得只有小key才会挪动,不够随机。所以这里的函数一般是随机函数,但由于我们必须保证原则“bucket数量不变时,k应该对应唯一一个bucket index”,所以这个随机不是真的随机。所以实现上,会拿key本身作为seed,它对应不变的一组数字序列,是可预期的。所以对一个key来说,在bucket总数不变时,结果是一样的。

文中还做了进一步优化,大概是能跳过一些没必要的j loop,毕竟j越大,能跳的值就越少了,有些都不太需要处理这一遭。没有仔细看,有空再啃一下原文,可以参考 https://zhuanlan.zhihu.com/p/625123381 看一看。

Ketama

前面那个属于邪教了,课外读物,当然很厉害。真的去实现理论算法的,较为有名的一种实现叫Ketama算法,该算法最初是由Last.fm的程序员实现的并得到了广泛的应用

标准ketama好像是 https://github.com/RJ/ketama ,只有c实现,其他语言都是调用c库。本质没什么特别的好像,可能就是较早的广泛的算法实现。

https://github.com/ultrabug/uhashring 是纯Python实现,其中也包含ketama。特点是,中途可以加减node,还有weight,weight就是大的vnode多一点,这样key会较多的到weight高的node上。这一版实现值得多看看。

redis(其实并没有)

redis常有人说用到了一致性哈希,具体是说redis cluster的data sharding。但作者明确指出它的slot sharding不是一致性哈希,https://redis.io/docs/management/scaling/。我们来分析下。

首先redis cluster data sharding的Hash槽就少一点,当然这不是拒绝被称为一致性哈希的理由。再就是一个redis node也是负责一堆slot,也可迁移slot,类似Vnodes,但不是Vnodes就一定是一致性哈希。因为redis cluster是让一个node服务一个slot,并没有当前node下线,就可以自动找下一个node的机制。因为node有存储,不是缓存,不天然满足一致性哈希的理论要求。但它倒确实是满足了“增删节点只影响一部分数据”这一条准则,让人觉得这就是一致性哈希才能做的事情。但还是要抓重点,redis cluster并没有为了做到能让下一个node服务,让nodeB多hold一份nodeA的存储数据。从这个角度看,它只是hash,很多分布式存储都哈希分数据,但它们也没谁说是用了一致性哈希。

1
HASH_SLOT = CRC16(key) mod 16384

redis的client jedis倒是用了一致性哈希,client/proxy端做请求分流,还是很简单的。

Dynamo

更标准的在存储端使用一致性哈希的例子,应该是Dynamo, 原文是《Dynamo: Amazon’s Highly Available Key-value Store》。再对比下Redis cluster和Dynamo,它们在哈希存储时,都面临存储node下线后就无法服务的问题,必然得想解决方案。最常规的办法是,为每个slot做一个多副本备份,slot副本之间用某种一致性协议保证数据同步。redis cluster也是如此,所以是master/slave机制,选主由外部服务sentinel负责。所以redis cluster架构其实就是普通分布式,当然它还有个特点是,用gossip协议组成集群,而不是中心化管理集群,实际工程上用起来贼难用。不管怎样,redis cluster是非常常见的hash shard+replica的分布式存储架构,只是具体选型有序别而已。

而Dynamo不一样,它硬是一条路走到底了。原文 https://www.allthingsdistributed.com/files/amazon-dynamo-sosp2007.pdf 。解读可以看 https://medium.com/@adityashete009/consistent-hashing-amazon-dynamodb-part-1-f5719aff7681 的系列文章,Vector Clocks,Sloppy Quorum等很多细节还没看。

dynamo

可以看到,它真的贯彻了一致性哈希的思想,为了能在B节点挂了的时候,能继续服务,让B之后的数个节点都放数据备份。或者看下面这张更细致的图:

chash-storage-detail

Dynamo也用到了Vnodes,但还是那句话,Vnodes是一个单独的机制,不是一致性哈希的核心,不影响上图的核心思想,所以Dynamo可以说是使用了一致性哈希的。

1
2
3
4
5
> when a node is removed, the next node (moving clockwise on the ring) becomes responsible for all of the keys stored on the outgoing node.

ref https://www.educative.io/answers/how-data-is-partitioned-in-dynamo

是一致性哈希的最核心思想,也是最重要的特点。

消息队列

消息队列的逻辑,就是做和内存队列一样的事。但它的使用场景,是分布式的。也不会是单一角色的,都分布式了,所以涉及多角色(或者说多进程)。于是,基本都会抽象为,producer发消息给它,consumer去从它那里获取消息。为了保证可靠和堆积,消息会被MQ持久化。MQ的核心是Queue,也就是FIFO。至于producer有几个,consumer有几个,consumer是pull还是被push,都是具体设计的事情。队列消费,听起来,一个消息按理应该只被一个consumer拿到,消费成功后,就会被删除。但实际也会有变种,有MQ不断发展,加新功能的,不会特别死板。

一个较简单的mq工程可以参考 https://github.com/zeromicro/go-queue 。注意它是封装者,核心不在它这里,比如它底层接Kafka。

实现

RSMQ

真的MQ算法实现,RSMQ是一种经典方案,简单,具体实现有不少项目。可以看看这个 https://github.com/mlasevich/PyRSMQ

One simple implementation of a message queue is using Redis. There is a Python implementation of Redis Simple Message Queue (RSMQ) called PyRSMQ . RSMQ emulates Amazon’s SQS-like functionality, where there is a named queue that is backed by Redis. Once the queue is created, producers can place messages in the queue and consumers can retrieve them .

Here’s an example of how you can use PyRSMQ to implement a simple message queue:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from rsmq import RedisSMQ

# Create a new instance of RedisSMQ
queue = RedisSMQ(host="localhost", port=6379, qname="myqueue")

# Create a new message in the queue
queue.sendMessage().message("Hello World").execute()

# Receive the next message from the queue
msg = queue.receiveMessage().execute()
print(msg['message'])

# Delete the message from the queue
queue.deleteMessage(id=msg['id']).execute()

这个项目是将redis作为远程存储,同时利用redis的一些功能,去实现一个MQ,不是简单的使用者。

Kafka

这是最经典的工程实现了吧,值得多研究研究。

一个队列的基本定义里就应该是有序,不过Kafka并非专注于MQ,它本质是一个分布式事件流平台。这一点要注意,Kafka只是可以用来搭建消息队列,但不是只能做这个。那么事件流和消息队列为什么是两个概念呢?比如,kafka里的一个topic其实也就是一个message queue了,为什么感觉名字差距很远?事件流为何不叫作扩展版的消息队列?

这个问题,我的理解是一个中间件,上下游同样都是生产者和消费者。生产消费者数量可以是多个,这不是什么难点。所以,没必要觉得MQ的consumer只能是一个,概念不至于这么僵硬。但它们对待输入的message的态度是不一样的。

消息队列注重的是传递消息,满足Message Queueing Pattern,通常保证exactly once。它从描述上就更像是P2P的东西,你一份消息传给多个消费者,感觉就像是消息被复制了一样,所以看到很多地方对MQ的定义就是单消费者,我认为单消费者MQ是经典案例,但不是多消费就被逐出MQ了(没有MQ内核,丢失了MQ的精神,joke)。MQ中,消息本身是用完就扔了,即使有一定backup,也不太可能设计为永久存储,存也不是MQ必须去做的事情,不然它叫什么队列呢,叫数据库好了。当然,真有人在MQ里保存永久消息也不是违法的,不必认为MQ就不可以干更多的事。

Message Queueing Pattern更多的是和Pub-Sub Pattern对立,Pub-Sub的不一样就在于,所有订阅者都应该拿到所有消息。它描述的就是MQ所不包含的一种模式。

而流streaming就是满足Pub-Sub Pattern的,这里最好不要把流当成“流水”那个意思,感觉好像也是水流过就走了,没留下什么。streaming是会将消息记录下来的,它像是log,它要记录下所有event。当你在某个时刻才加入一个消费者时,它也可以获得全部的event。我们谈到streaming,它就应该做得到。如果是MQ的设计,要存下所有message,就不是很像queue了,你不能去要求所有MQ系统都给你这样的功能。如果你就想MQ补充这一功能,那也OK。

streaming通常被说带有实时的意思,我感觉并不是,real-time streaming才是。参考 https://sqlstream.com/real-time-vs-streaming-a-short-explanation/,streaming更多是在说它是源源不断的,持续生成,看不到尽头。区别于batch,是一个批次被处理(每条消息都看作一个batch,谁又能说streaming不是batch呢,joke again。这些概念可能没有那么泾渭分明,那也可能是我理解不够深刻)。事件是源源不断地产生,这就叫做event streaming。

所以,这两个概念可以说是没啥关系,不是非要取分出个所以然。听到某个系统是MQ,你就知道它会给你MQ基本的功能,但它可能还有更多的扩展,它可能做到更多的事情,能当流用,也可能因为它的具体设计,它其他啥也做不到了,但它就是很好用、很适合你的MQ。

https://kafka.apache.org/intro 官方文档也介绍了Kafka一般的使用场景,反正满足要求你就可以用。Kafka的topic可以pub/sub,所以,就可以拿Kafka作为MQ的底座。

架构与有序

Kafka架构上就是可以给topic分区,每个partition也可以有多replica,几乎等于分布式db的架构。也正因此,各个partition之间是无法有序的,就像分布式db中按hash分片的table一样,不能天然支持range scan。单partition当然可以做到有序,不过,能放开限制自然是放开更好,能利用上分布式的优势,并发、容灾等。

TODO kafka log设计

Log

万物基于log,log门。

https://engineering.linkedin.com/distributed-systems/log-what-every-software-engineer-should-know-about-real-time-datas-unifying
作者提倡把log作为一种抽象的概念,就像我们谈到hash,不会去具体讨论它是murmur hash还是别的。我们就把log作为一个已知的概念,不去讨论它是什么,而是去讨论它能做什么。

So a log, rather than a simple single-value register, is the more natural abstraction.
Log可以表示a sequence of requests,也可以说是changes,它是一种很符合现实的抽象概念。程序基本都是在跟持续的请求打交道,而不是单一的请求。单一的,比较简单的例子就是数学计算,与别的无关,没有状态,只要给我同样的输入,我就会给同样的输出。还有cache,我就是只get/set,先到先得。但现实中,基本不是这么简单的情况。

拿Table和Log的关系来分析,Table是当前瞬间的状态。而Log是一个序列,包含了所有的变化,也可以说,它保存了所有的历史版本。历史版本,就让人很容易联想到source code version control,每个commit并不是记录当前的所有code,而是记录change。

介绍三个方向,分别如何使用log:
Data Integration—Making all of an organization’s data easily available in all its storage and processing systems.
Real-time data processing—Computing derived data streams.
Distributed system design—How practical systems can by simplified with a log-centric design.

  • Data Integration
    类比马斯洛的金字塔需求模型,data就是最底层的需求。(开始玄学,但确实也没说错。)许多现实系统中,其实data就没准备好,Data Integration做的差,就在此基础上搭分析、建模等模块,和搭房子一样地基不稳。
    Data Integration为什么难,作者认为是两个趋势导致的,一是event data井喷,二是data system变得非常专用。第二个好理解,很多现实场景遇到的挑战,已经不是某个data system可以解决的了,所以出现了很多细分的领域和功能各有优劣的system,例如,olap,oltp,online,batch,streaming,graph等等。第一个event data的话,我理解不深,目前的理解是event data区别于entity data,它描述的是事件、行为,而不是某个东西的最终样子,例如某时刻的点击,某时刻的登录,而entity data例如用户名、id、地区,更静态。如此区分data,主要还是没有万能的银弹,区分后会更适合去处理它们。而单独划出来的event data,传统的DB很难撑住,也就诞生了更多品种。如今的data system确实是相当多,无法将它们视为一个整体,经常要去处理它们谁流到谁那儿去的问题。

    而不同data system如何同步数据?都可以抽象为log-structured data flow。Data Source将数据变化写入log,其他data system作为subscriber,读log并自己追上数据变化。无论subscriber是哪种system,这个模型都是行得通的。就算subscriber是个cache,它实际并不存log,但它也可以通过log来同步数据。而且,log自带版本概念,可以很清楚的知道自己从这些subscriber中读取的是不是最新数据。还有一点是,log可以当作是buffer,不同subscriber消化数据的速度是不同的,也可能在某个时刻,subscriber down了,那么log还在,重启后也能追得上。从图上也可以看到,log是一个用来解耦的概念,subscriber只需要懂log,它不需要知道Data Source长什么样,这也是一个优点。

    在作者公司linkedin中,实际的场景就是

    通过Log,架构变为

    非常简洁的架构,也可以看出Log抽象的价值。(其实这也就是kafka的简要架构图了。)

    在这样的架构基础上,实际还有一些点需要考虑,就是ETL,数据并不一定是都有意义的,结构可能不是统一的,很多场景里ETL(cleanup or transformation)后的数据才值得使用或存储(存入数仓)。ETL后的数据还是一样要考虑到subscriber可能是batch的数仓,也可能是实时的什么系统。前面的架构图看不出具体的角色,所以我们应该用下面这样的详细一点的图:

    图中有三种角色,publisher,log system,consumer,ETL放在三个角色的哪个中,都是有说法的。具体情况具体选择,比如,数据肯定不需要被consumer收集的,就应该在publisher处直接扔掉,也就是cleanup,没必要走接下来两步。有些数据是否被consumer收集,需要看它的值,那它们肯定存在于log内,只是有些流不会传出它们,相应的consumer也就接收不到。也有些操作可能只能在consumer端做,比如针对consumer背后系统的aggregation,其他系统都不需要这一阶段,那肯定得它独自增加。

    Scalable Log,实际为了做到多subscribers,没有那么简单,所以他介绍了kafka log的设计如何满足scalable。暂时不看了,后面读kafka再说。TODO

  • Real-time data processing

    Stream processing has nothing to do with SQL. Nor is it limited to real-time processing.

    Yes,俺也一样,我也觉得real-time和stream并非绑定关系。

    I see stream processing as something much broader: infrastructure for continuous data processing. I think the computational model can be as general as MapReduce or other distributed processing frameworks, but with the ability to produce low-latency results.

    batch和stream的概念,他也举了个例子,US人口普查,人口统计是下到挨家挨户统计,记录在纸上,然后this batch of records传到中央,中央把全部加起来。它不是每个地区都有a journal of births and deaths,用它来算当前人口,甚至能算到某一年的人口。这不是说journal形式就很优秀,而是各有各的区别。有些数据就是batch进来的,你就应该batch处理,有些数据就是continuous feeds,那肯定就想要能够能平滑地处理连续数据,而不是转为batch。这是自然的应对方式,当然,如果分析下来,batch没什么不可以,那也可以batch处理。要平滑处理数据,还有就是,不可能数据1s来一条,你stream处理它花5s,来不及消化的数据能堆积到明年,所以stream通常是要求low latency的。

    Log在real-time data processing中怎么工作的,进一步可参考https://samza.incubator.apache.org/learn/documentation/latest/core-concepts/core-concepts.html。他聊到了Data flow graphs,Stateful Real-Time Processing,Log Compaction

  • Distributed system design

    The final topic I want to discuss is the role of the log in data system design for online data systems.

TODO 为什么分布式一致性协议全都是需要log?它们不考虑整个table原地修改?这个话题还是分布式协议再讲。paxos multi-paxos raft这些。
附加一个paxos参考 https://zhuanlan.zhihu.com/p/341122718

分布式锁

锁的概念不多赘述,分布式锁当然是分布式里用的锁,语义是一样的。举个例子,分布式中同一个角色有多个时,它们很可能需要协调谁可以写,另外的人此时不要动。每个人都觉得自己该写,那当然要一个第三方角色,来觉得谁能谁不能,要达成共识。常见分布式锁的实现是基于数据库,基于Redis,基于zk的。

TODO 为啥有个印象,有消息队列做分布式锁?
估计是经常有人说redis实现这两个
以及消息队列会用到锁

现实考虑,已有消息队列时,利用它可以不用加其他的依赖。

但mq的分布式锁是有限制的?模型如果是msg发送,收到msg的认为自己是主,那么msg定时发送的话,下一次的主就不一定是当前这个。如果是利用sub订阅的排他性?只有一个consumer能连通,其他consumer都在等着,那倒是可以。

这篇文章介绍的redis做分布式锁,也有redlock和zk的一些理解,还提到大佬对redlock的讨论,读一读

Why Use Parallel Computing?

blabla.
但真正让我感受到计算能力的有限,是在一次物理大作业中。具体的内容记不清了,但反正需要matlab写出三重for循环,来找出极值。然后CPU就开始疯狂转了,等了老半天才出结果。
虽然这个作业能不能并行化,我也记不清了,但是,它让我体会了电脑算力不如我想象的高(我还用的是笔记本电脑,性能比较差)。以前我真的认为要很大的数据规模,才会需要服务器,超算。

直到后来更加系统学习了算法的一些知识,每次估算算法时长时,才发现,CPU的几GHz其实也挺小的。算出某个算法要几个小时才能跑完时,就会想,CPU如果再快个几倍该多好。

semaphores https://greenteapress.com/wp/semaphores/
java https://docs.oracle.com/javase/tutorial/essential/concurrency/procthread.html book … in practice

books, need to read?
https://www.amazon.co.uk/Introduction-Parallel-Computing-Ananth-Grama/dp/0201648652/ref=sr_1_2?ie=UTF8&s=books&qid=1279904224&sr=8-2
https://www.amazon.co.uk/Parallel-Scientific-Computing-MPI-Implementation/dp/0521520800/ref=sr_1_3?ie=UTF8&s=books&qid=1279904267&sr=1-3

一些演变过程

参考《Java Concurrency in Practice》第1章。

最初,只存在单进程。后来,操作系统引入多进程,多进程可以简化代码编写的难度,因为你可以只对一种任务写一个程序。各个程序间可以交互,而调度交由操作系统。
-> 比在单进程中塞各种任务更容易。(此时串行的基本单位是进程,所以这里说“单进程”)

后来出现多核,多处理器,单个程序也可以不串行,也就是多线程。不仅是提高资源利用率,而且把一个程序拆成多个子任务,每个任务的逻辑单独编写,必要时再交互,也可以简化程序逻辑。
-> 比在单线程中塞不同子任务更容易。(因为此时有多线程了,串行的基本单位是线程,所以这里说“单线程”。实际要表达的是一样的。)

但由于操作系统也是慢慢进化的,早期的操作系统通常会将进程中可创建的线程数量限制在一个较低的阈值内,大约在数百个(甚至更少)左右。因此,操作系统提供了一些高效的方法来实现多路I/O,例如 Unix 的 select 和poll 等系统调用,要调用这些方法,Java 类库需要获得一组实现非阻塞 IO 的包 (java.nio)。然而,在现代操作系统中,线程数量已得到极大的提升,这使得在某些平台 上,即使有更多的客户端,为每个客户端分配一个线程也是可行的。

也就是说,理论上肯定是多线程+阻塞I/O更好(指线程中使用BIO),可以降低开发难度,线程调度不需要我们操心。但现实是,过于多的线程就可能带来问题。线程使用的内存,线程上下文切换的CPU开销,在“过于多的线程”这个场景下,可能就是内存占的很多,处理速度也不快。可能使用NIO效果会更好,当然,是在某些场景下,无法绝对论。所以,NIO还不会被淘汰(虽然它真的有点复杂,不好理解,又是考点😂)。

ThreadLocal

Java中线程封闭里有个特别点是ThreadLocal,它就是简化一下写法,并不是什么黑魔法。

首先,理解下ThreadLocal的原理,你使用ThreadLocal::get/set时,内部是去取该线程的ThreadLocalMap,注意Map是存在Thread里的,set就是set值到这个Map,kv是<this(指ThreadLocal对象), value>,那么也说,一个ThreadLocal对象是同一个没错,但实际get出来的是从某个Thread对象的ThreadLocalMap里拿出来的value。(而不是一个ThreadLocal里存thread到value的映射)

需要注意,每个Thread里对ThreadLocal对象做set,是线程隔离了,各个线程之间不共享。但要是你set同一个value,那本质上还是多线程共享同一个对象。

那么,什么时候用的上ThreadLocal呢?为什么要用它,而不是每个线程都维护一个自己的对象?

网上大部分的ThreadLocal举例都是什么UniqueId、DateFormat,它们完全体现不出ThreadLocal的优点,反而像画蛇添足,原子变量都可以的为什么非要ThreadLocal写UniqueId,每个线程就可以有自己的成员变量直接new DateFormat不就行了,为什么要ThreadLocal来创建DateFormat。

我们先抽象一下场景,假设Thread内会创建某个对象,不管合不合适,我们将它存在Thread类的成员变量里,那么,多线程是new 多个Runnable或者多Thread,才能做到线程之间互不干扰。

如果是create一个Runnable,多线程都由这一个Runnable初始化,那这里就有限制了,多Thread实际用的一个Runnable对象,那多线程肯定就互相干扰了,这时候把Runnable里的成员变量直接改为ThreadLocal的,就可以立马获得线程安全的程序。这是一种ThreadLocal的好处,它可以简单地将单线程程序改成多线程程序。

再考虑这样一个场景,我们有多个函数,它们不是Thread/Runnable的函数,是别的类的,它们的输入都是context,每个线程单独一个context,那使用方式就是:

1
2
3
4
context = new Context
A.func1(context)
B.func2(context)
...

那如果你不想这么频繁传递,比如,A.func1()这些函数内部可以写为:

1
context = ContextTL.get()

它们就自然获得该线程的context,那么这个ContextTL自然用ThreadLocal来做,就可以达到目的。使用方式就变成了:

1
2
3
4
5
context = new Context
ContextTL.set(context)
A.func1()
B.func2()
...

单实例变量或全局变量共享,就是这么个意思。没有ThreadLocal的话,set 类static变量当然是线程不安全的,有了ThreadLocal就可以。在线程的角度看来,就像是单线程的情况下,去set一个跟别的对象共享的变量。

很多题目都有基础的数据结构和算法,但也有它独特的corner case。所以,本文主要记录抽象的算法,避免重复记录。

Quick Sort

快速排序

快排容易想的方法是3个while,一个while外循环,加每次左标移动和右标移动两个while。但这个翻译成代码很容易出错,主要是感觉while的保护和退出条件不容易记忆。出错了,你没法跑单测就很难补好洞。虽然面试的自己可能都写不出来,但他复制你的代码去跑,那就还是你gg。所以我倾向于记忆单循环的写法,主要是不需要什么保护和退出,简单。不过,3 while确实更加贴近抽象算法的原理,可以尝试记忆一下。

我们记锚点为pi,它位置上的值为piv。

单循环思路

快排建议记忆单循环的写法,该写法也不用死记,需要记住几个点:

  • 单次循环,那么就是把每个比piv(pivot的value)小的数都挪到前面去。
  • 第一次挪怎么挪?第一个数是piv,从第二个数开始比大小,如果第二个数小于piv,是原地不动还是扔到第一个位置?这里重点记忆不要交换pivot,pivot最后再交换。可以推理出,第二个数如果小于piv,就应该放在第二个位置,就是原处。如果它大于等于piv,它不动,就比下一个数,如果它小于piv,就挪到前面去。
    • 总结规则是:当某数大于等于piv时,是跳过,直接看下一个数。如果小于piv,交换,交换到此处的值,可能是大于等于piv的,也可能就是原地交换(比如piv就是最大的值,就会一直触发原地交换),但反正不用再次考虑,还是可以直接下一个数。
  • 遍历完成后,就形成了三块区域,[piv, < piv, >=piv]。这个时候交换piv和< piv的最后一个数,区域就可以变成[< piv, piv, >=piv],符合快排partition后的结果。
  • partition函数有了,快排的递归partition怎么写?重点要注意的是怎么标记partition的输入范围,是左闭右开,还是全闭?目前感觉都可以,因为快排的衍生问题都是pivot很重要,区间问题不影响pivot,pivot不会漂移。
    所以伪代码写为:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    def partition:
    # [lo, hi)
    piv, j = a[lo], lo
    for i in range(lo+1,hi):
    if a[i] < piv:
    j ++
    swap i,j
    swap lo, j
    return h
    def qsort:
    if lo + 1 >= hi: # 长度为1或0都不需要排序
    return
    pi = partition(a, lo, hi)
    qsort(a, lo, pi) # 这里指排序[lo,pi),pi不参与排序
    qsort(a, pi+1, hi)

如果partition函数像做成全闭的,那么< piv的游标j和循环的取值范围,以及qsort里的退出条件和qsort递归范围变化一下就好了,没什么坑。
如果侥幸你记得pivot可以选最右(我每次都记不得),那么< piv的游标j和循环的取值范围要改一下,以及最后应该交换pivot和j+1。感觉没简单化什么东西,不必记忆。

判断条件用 <= 或 < 应该都可以,只要能保证最后pivot的交换能满足快排定义。看网上题解 <= 多,但我觉得 < 挺好用的,减少一些swap可能,比如这个题解就是用的小于做判断oj上也没测出问题来。

3 while思路

3 while思路,推荐在陈斌老师的写法上改进一点点,也就是去掉done这个flag,没什么必要加它。3 while思路的难写点在于,双mark写法过长,可能做不好越界保护。

首先看基础思路:

我们先暂时放下while的保护条件,先讨论理想情况。两个游标怎么游?算法中不是要< piv, piv, > piv,毕竟可能piv有多个,而是<=piv, piv, >=piv,注意,两边都可能==piv,不是必须在哪一边。因此内层两个while判定条件是<=piv ++, >=piv –,遇到等于的可以继续游,不需要停下。
所以伪代码可写为:

1
2
3
4
5
6
while True:
while … and [lmark]<=piv: lmark++
while … and [rmark]>=piv: rmark--
some cases
exchange
handle pivot

很明显,内层2个while不可能让2个mark一直++/–,所以我在2个while判断的最前面都留了保护条件。

让我们思考下这个保护和2个while结束后,哪些情况不能走到exchange,或者得直接退出外层while。

首先,可以提醒自己交换价值,就是2个mark什么时候是需要交换的,值得交换的,不值得的时候,可能要跳过或退出(也就是上面代码的some cases)。通常的看,当然是lmark < rmark时应该交换,lmark==rmark和lmark > rmark时都是不应该交换的。那么,假设走完内层2个while时可以存在lmark==rmark的情况,我们该干什么?

如果while保护条件为lmark < rmark,那很可能出现这个lmark==rmark。可能是lmark知道了前面的值<= piv,所以++到了此处,由于保护条件,第2个while就也跳过了。又或者lmark确实到了此处,它>piv,rmark又走过>=piv的值,到了此处,被保护条件卡住。可能还有别的情况,但总的来讲,就是你无法明确lmark==rmark时,这个位置的值是个什么情况,它没有保证。为了这个情况,就得单独去判定大小,并看往哪儿放。不是说不可以这么写,就是挺麻烦。

举例说明:

[5,7,2,8]它以5为piv,会交换一次7和2,成为[5,2,7,8],然后就会遇到lmark==rmark,也就是7所在的位置2。简单地想,pivot 5和双mark指向的7应该交换,但是这个例子不能,得先判断出7比piv大,不能动,然后往左看到2,发现它可以和piv交换。这里又会衍生,要不要保护,pivot是不是就是最小的,不用交换等等情况。特殊情况就很多,很烦,容易想漏想错。

所以再换个思路。如果保护条件是lmark <= rmark,就会发现,走过2个while后,不存在lmark==rmark的情况(可以assert看看)。因为一个数它跟piv比只有三种情况,而三种情况,两个游标都不可能同时停下,必然有人要跨过去,所以安全。那么,lmark>rmark时我们退出外层while,这时候如何处理pivot?会不会比前面思路的处理逻辑简单?

lmark > rmark除了这个位置关系,数组当前会是什么情况,能保证一些什么关系?首先明确它们只可能是lmark=rmark+1,就是相邻的,不可能交错后继续拉开距离。

  • lmark > rmark可能是lmark先走,触发保护,然后rmark的while直接跳过。这个情况,lmark都能穿过rmark,自然lmark的左边(不包含自己)都是<=piv的。此时rmark可能是走动过,也可能是完全没移动过,但都ok。走动过,那么它必然是停在了< piv 的值上,也保证它的右边全是>= piv的。这个大小关系下,很自然,pivot和rmark交换下就好了,把< piv的值放到最左边,很合理。如果rmark没走动,那它就在最右边,lmark又保证了除pivot以外整个区间都<=piv。这个情况把pivot扔到最右边也很合理。
  • lmark停在了>piv的值上,rmark一直往左,穿过了lmark。这里也保证了rmark的右边都是>=piv的,rmark当前位置好像unknown,因为rmark只是因为保护停下。但考虑到lmark停在此处,当然是rmark停在了<=piv的值上,而lmark在紧邻的右边。这个关系下,pivot和rmark交换,也很合理。那如果lmark压根儿没走动,rmark就走到了pivot这个最左边的点上,原地交换下也没错。

可以看到,保护条件是lmark <= rmark时,rmark指向的值有明显的大小保证,不需要再次确认什么。所以,用lmark <= rmark这样的保护条件最好,对应的lmark > rmark时退出外层while。重点记忆,“交错”(lmark>rmark)就停止,自然,不交错的话游标还可以继续跑,所以内层2while的保护条件是lmark <= rmark。(这个记忆法仅用于记忆,没办法去推理代码的完备性。)

总结

核心是“交错”,交错就停止,只要不交错,3 while都可以继续

1
2
3
4
5
6
7
while True:
while lmark<=rmark and [lmark]<=piv: lmark++
while lmark<=rmark and [rmark]>=piv: rmark--
if lmark > rmark:
break
exchange
exhange rmark and left

实际oj测试,还是双mark速度快点。

另一种写法见leetcode题解,3 while用的i < j,注意写法是右标先移动,否则有问题。这种大概率记不住,先左后右比较顺。

Binary Tree Traversal

二叉树遍历

二叉树的三种遍历递归写法都没难度,不过稍微加点东西就容易搞不定了。还是不够熟悉递归,不够熟悉将稍复杂的逻辑想清楚,所以转换为代码会卡住。

另外,再注意一下,先序、中序、后序都是看的当前节点cur,所以先是cur-left-right,中是left-cur-right,后是left-right-cur。不要认为后序就是right要放前面了,有时候容易脑子短路。

中序遍历

https://leetcode.cn/problems/binary-tree-inorder-traversal/

中序递归遍历,一般就用result数组来存结果就行了,因为result在递归中也是一个一个append结果的。拼接的写法inorder(root.left)+[root]+inorder(root.right)大可不必,空间消耗有点过分了,也没简单多少。

而中序迭代遍历,该怎么写?首先得加个辅助结构,或者改当前的数据结构,否则不够用。

我们先说“加辅助结构”的算法。中序是左-中-右,递归不用额外结构因为它可以回到中,即使已经在左子树游了一圈了。那迭代就需要记住中,因二叉树单向的,往下走回不来,不缓存起来就回不去。不管是栈还是队列,一维数组肯定够了。当然一般“递归->迭代”都是栈,递归本质也是先进后出。

再看怎么使用栈,其实就是模仿递归,递归往左下走到底后,可以逐步回来,所以迭代里就应该往左下走,并且记录每个“中”节点。到底后,就可以向result加结果了。拿最左的叶子节点举例,它左子节点None了,就类似递归回到了它,它作为中节点,被加入result,那么此时还应该考虑它的右子节点。而以这个右子节点作为起始,做的实际还是中序遍历,不过是子树的中序遍历,所以这里代码可以复用,只需要以右子节点作为起始节点。可以看出,中先进栈,左子再进,把左子和中都弹出栈后,右子才会进栈,不用担心右子节点遍历完了后就断了,因为中的上一层还在栈里面。

伪代码写作:

1
2
3
4
5
6
7
8
9
10
cur = root
stack = []
while ?:
while cur:
cur -> stack
cur = cur.left
# no more left child, cur == None now
top = stack.pop
top -> result
cur = top.right # next loop will traversal the right child

最后看终止条件怎么写,stack为空,没法pop,肯定得保护,但是因为内层while也在填充stack,所以并不是stack为空就得终止,内层while可能会补充。不过如果cur和stack都为空,那就没必要了。

所以终止条件为while cur or stack

前序遍历/先序遍历

https://leetcode.cn/problems/binary-tree-preorder-traversal/

递归写法是“中-左子-右子”,因为中就是当前,左子就.left往左走就行了,所以只需存右子,且是先进后出。用简单例子演示一遍也可以知道,伪代码可以写作:

1
2
3
4
5
6
7
stack, cur = [], root
while ?:
if not cur:
cur = stack.pop
cur -> result
if cur.right: -> stack
cur = cur.left # be the new traversal root

最后看终止条件,stack为空,如果有cur,也可以走下去(比如root节点);如果cur为空,stack还有,也能走(比如走到树的最底层,但树右边还有节点没遍历到)。所以终止条件还是while cur or stack

后序遍历

https://leetcode.cn/problems/binary-tree-postorder-traversal/

依然要记牢,迭代就是模拟递归,所以这里依旧需要用栈来模拟递归时天然拥有的栈信息。递归是“左子-右子-中”,所以还是先把最左边的路走通,存下每个左子。每个左子从栈中取出时,我们假设某个节点名为node1,都应该先把node1右子当作新的root,把右子树走完,才回到这个node1。只拿到node1,是可以找到其右子的,但是node1此时不出栈,我们会再次拿到它,如何区别它的两个状态(该让右子进栈,还是就该它出栈,它完成遍历)?

这里比较常规的想法是要不栈就存< node, flag >,通过flag来,进栈的flag都为false,如果我们将它右子放进栈,就把flag改为true。下次pop出flag为true的node,就跳过压右子,直接让它进入result,视为它和它的子树都完成了遍历。

但其实还可以更简单,需要察觉到一个关系问题。比如,栈目前是[1,2],然后2是叶子,2出栈。然后我们看到1,1有右子3,那么此时就是[1,3],注意一定有node1.right==node3这个关系。3的子树怎么操作,不用关心,假设它已经完成了,此时应该3出栈了,栈又变成了[1],而如果我们记住了3,当我们看到栈里的1,有node1.right==node3时,就知道了,node1的右子node3已经访问过了,node1可以出栈了。这个关系完全是即时的,它只需要记住出栈的节点,所以只需要一个变量就可以,通常记为pre。pre可行的原因就是遍历的连续性,右子节点出了,马上就是中这个节点出,中间不会夹杂别的。记住右子,就能判断出中是第二个状态了,不需要再管右子节点。

skiplist

https://mp.weixin.qq.com/s/czkZcQL8mEqG2xeX8huqsA

Heap

堆,一般指二叉堆。

我们希望有一个数据结构,它可以迅速pop出最小的,push一个新的数也希望是对数时间里完成,这样的时间复杂度才有价值,不然简单的有序数组pop/push都是O(n)就足够了,还简单。

这个理想的数据结构,应该考虑树形,毕竟对数时间。

再说什么树,因为没有什么限定条件,二叉树就是最简单的。然后再想,n个数去构建二叉树,如果n不是2^k-1,那这个二叉树就不满,不满也不能乱搞,因为对数时间,你当然希望这个树足够矮小,不然最坏情况又是线性了。所以平衡二叉树是理想形态。

而平衡二叉树并不必须用链表作为节点,它可以用连续数组表示。这也是让代码更简单的一个优点。

再来说说堆的基本操作逻辑。

一个是pop,pop出堆顶后,树的根节点就空了,这个“树”就不是有效的树了,那得想个办法修好,办法就是把最尾部的元素移动到堆顶,这个时候不满足堆有序,那么就去让它有序,画画图也可以发现,只有堆顶也就是根节点不符合堆有序,那么调整根节点和它的两个子节点,比如它和左子节点交换,它们三个就堆有序了。但因为左子节点变了,它和它的两个子节点可能又乱了,就继续调整。一层一层往下,也叫做“下沉”。只会沿着某一条路径下沉到叶子,所以复杂度是logn,不会影响别的路径。

二是push,新增一个元素到已形成的堆,将新元素塞入树中间显然不合理,放在最后一个位置,就像是多给某个节点加了个子节点。然后,这个节点和它的一个或两个子节点,可能不满足堆有序,就需要调整。调整后,它作为子节点,可能又不能有序,于是就一直“上浮”,上浮到根节点就结束了。同样的,只会影响一条路径,也是O(k)(k层的树,n个节点树高也就是k=logn)。(如果当前堆已经满了呢?新加的节点,在逻辑上看,就是树多加了一层,这一层只有一个节点。说得通。)

Python heapq

再提一嘴python的heapq,优先队列一般底层结构就是二叉堆,具体二叉堆怎么实现都行,python这里也是使用数组,但是它的内部方法实现不是前面提到的算法,它更高效,可以仔细看下heapq的实现和注释,实名diss了前面的常规算法???。但这个不影响时间复杂度,不可能比logn更小,只是时间优化。(heapq算法里的siftup,siftdown和上浮下沉就对不上了。有空可以学习下。)

heapq原理在代码里也不过,就观察下整个源码实现吧。

  • heappush,就是新加一个elem到heap,源码实现为,append到数组末尾,然后siftdown(0,last),第二个参数last指从这个节点开始,往上走到第一个参数0,也就是根节点。其实就是一次上浮(对应Algorithm 4th的swim),但不知道为什么叫down?

    Pasanen, Tomi (November 1996). Elementary Average Case Analysis of Floyd’s Algorithm to Construct Heaps (Technical report). Turku Centre for Computer Science. CiteSeerX 10.1.1.15.9526. ISBN 951-650-888-X. TUCS Technical Report No. 64. Note that this paper uses Floyd’s original terminology “siftup” for what is now called sifting down.
    https://en.wikipedia.org/wiki/Binary_heap#cite_ref-13

    Floyd大概是这一篇,里面siftup确实是j=2*i,往下走的,可能python heapq按照这个逻辑来的吧。

  • heappop,和抽象的算法没有区别,把堆顶拿走,把最后一个数放到堆顶,然后下沉(sink),只是heapq里叫siftup。siftup有个神奇的点是它在最后call了一次siftdown。注意整个siftup的实现,简化一下是这样的:

    1
    2
    3
    4
    5
    6
    7
    8
     siftup(pos):
    newitem = heap[pos]
    while childpos < endpos:
    childpos = max(left, right)
    heap[pos] = heap[childpos]
    pos = childpos
    heap[pos] = newitem
    siftdown(pos)

    它并不是在while里,用当前pos跟它的两个子节点比大小,选择跟哪个子节点交换,而是无条件交换。也就是说while不是一次完整下沉,所以是while+siftdown组成一次完整下沉。
    看下它的注释

在这个函数之前的评论区中,作者已经解释了他们为什么做出这样的选择,这初看起来效率较低,但在实践中却发现会导致更少的比较。

We could break out of the loop as soon as we find a pos where newitem <= both its children, but turns out that’s not a good idea, and despite that many books write the algorithm that way. During a heap pop, the last array element is sifted in, and that tends to be large, so that comparing it against values starting from the root usually doesn’t pay (= usually doesn’t get us out of the loop early). See Knuth, Volume 3, where this is explained and quantified in an exercise.

heapq提供heapreplace,也就是把堆的pop和push两步放在一步完成,因为size恒定。你如果pop[0],就把新的元素放在[0]就行,让它下沉。

注意heapq.heapreplace是一定replace的,它不会管你想push的元素会不会比heap top更小。它一定输出heap的top,再把push值塞进heap里。不过我们这个题目,是可以保证push的大于等于heap top的,不会有问题。

如果没有这一点保证,你想做的事情就应该抽象为“push后pop”(当然内部应该优化,不是愚蠢地真的push后又pop),因为你想从heap和想push的新值一起看的集合中最小的值,可以使用headpq.heappushpop。不过,这个自己写也行,逻辑不复杂,就是先peek一下堆顶,如果push的新值更小,就直接返回,如果不是,就把堆顶值取出,把新值放在堆顶,然后堆化。

Ugly Number

https://leetcode.cn/problems/ugly-number-ii/

丑数,听到题目很自信,dp[i]表示i这个数字是不是丑数。然后直接超时GG。一个重要现实,大部分的数不是丑数,所以会很膨胀,比如n=476时,第476个丑数已经达到737280,73w了,太多数字不是丑数了。所以不应该用动态规划的i表示i这个数,而是表示第i个丑数。

去判断1个数字是否为丑数,这种一次性的判定,不去利用缓存是可以理解的,但找第i个丑数,当然不可能每个每个数字去判定,应该去找与前面的丑数的规律。所以,如果没想出动态规划怎么做,收集所有丑数,直到第n个,也是个办法。由于一个数2,3,*5都是丑数,但下一个丑数 *2不一定比前面的丑数 *3大,所以得排序。于是有了直接把数字插入堆里排序,这种方法,保证每次拿出最小的丑数来。第一次将1放入,1取出,2,3,5都放进去,反正1是最小的,可以安心输出。再从堆里取最小,2,2的2,3,5倍都是丑数又放进堆里。但注意得到2的3倍6进去了,3的2倍6又会进入堆一次,所以堆中拿出的丑数有重复,堆中拿出的丑数需要确认无重复,可以用map/set的key来去重,等map/set收集到第n个丑数时,停止收集。

再仔细理解一下人的解题思路。我们知道最小丑数是1,此时只能衍生出3个丑数2、3、5都是丑数,就三个选择,取最小的2,作为下一个丑数,而这个丑数又可以衍生出22,23,2*5三个,加上前面还没用上的的3和5,可以看到,这么想的话很膨胀,备选数会越来越多。但当前最后的丑数2,它的3倍5倍都很大,没必要此时就参与比较,只有2 * 2较小,可以和1的3倍和5倍比一比,谁是下一个丑数。但它们也不能被丢掉,后面的比较里它们也要成为备选。所以,换个角度,不要从“每个数的2、3、5倍都是丑数”这样发散,而是从“每个丑数都要被2、3、5乘”来考虑。也就是说,2、3、5它们三个要跟我们已生成的丑数序列的每个数都乘一次,且只需要一次,用完就扔,跳到下一个丑数去。

这个题解的说明更加清晰,推荐阅读。

这个思路可行后,还需要注意2 * 3和3 * 2这个重复问题,以2为例,看到当前的a[i] * 2 == a[-1],那么应该取下一个a[i+1] * 2作为竞争者,跳过a[i]。所以我的代码是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Solution:
def nthUglyNumber(self, n: int) -> int:
a = [1]
L = [0,0,0,0,0,0]
# L[i] = pi, candidate is a[L[i]]*i, i = 2,3,5
while len(a) < n:
if a[L[2]]*2 == a[-1]: L[2]+=1
if a[L[3]]*3 == a[-1]: L[3]+=1
if a[L[5]]*5 == a[-1]: L[5]+=1

# 3 candidates
m = 2
min_n = a[L[2]]*2
if min_n >= a[L[3]]*3:
m,min_n = 3, a[L[3]]*3
if min_n >= a[L[5]]*5:
m,min_n = 5, a[L[5]]*5
L[m] += 1
a.append(min_n)
# print(a)
return a[-1]

就是在侯选前,去跳越重复值。
但更简单的写法是,在后面跳跃,比如[1,2,3,4,5]后的下一个丑数备选是3 * 2,2 * 3,2 * 5这三个,它们取出的最小值是6,但没必要取看它是丑数 *2还是 *3的结果,因为这两个指针都应该++。所以,更简洁的代码是:

1
2
3
4
5
6
7
8
9
while :
min_n = min(a[p2]*2, a[p3]*3, a[p5]*5)
if min_n == a[p2]*2:
p2 += 1
if min_n == a[p3]*3:
p3 += 1
if min_n == a[p5]*5:
p5 += 1
a.append(min_n)

BigData Interview Questions

涉及大数据的面试题,通常是没法写代码的。当然,也有部分题目本质上是归并、堆或是快排等算法,那就还能写写。

说说常见试题。

常见的就是给定一个大数据集,但是内存小,不能做到一次性load数据并处理。例如,给1G的文件,内存限制在1M,等等类似的条件。

大数据必然是“分治”,大数据都要能拆成小数据。所以,遇到大数据题目,可以先假定能拆分出小数据,即使还不知道应该hash拆分还是别的算法做拆分。(当然,通常都是hash算法拆分。)

那么,问题就被分割了。我认为应该更早去考虑小数据集的部分,跳过拆分。因为,在考虑小数据集如何处理时,你会想到小数据最好是什么样的组合形式。

比如,如果是word count类,你自然希望小数据里可以直接包含某个word,这个word不在别的小数据集中出现。那么自然的,拆分就应该用hash算法,它能保证一个word都被hash到一个片里。再比如,如果你是希望小数据集之间能有一定顺序,可能按首字母来排序,那么切分数据集时就应该按着首字母来分出26份,这里用hash算法就没啥意义了。(当然,hash是更常见的,无论是题目还是生产中。)

大部分题目,在拆分为小数据集后,就基本默认小数据集满足内存要求了。也就是只做一次hash拆分。更进阶的,应该要考虑到如果一次切分后,有些小数据集仍然过大的情况。

很多网上的答案都是再hash,但这个也不是一定能分出来,或许面试不会问这么细节,不过也值得考虑一下。

我认为实际代码里不可能花时间去不断hash,通常试几次,不好使,就可以放弃了。如果碰到hash后的分片,它就是有点大,那可能它里面只有几个key,甚至只有单key。那应该如何处理?work count类的问题,需要在内存维护的是map,而不是原始数据,那么只要key不丰富,在内存内就装的下。1G的大数据,可能有超过1M的不同key,但都拆的较小的情况下,还有1M不同key,可能性还是很小的。

真要假设超过1M的这个分片里,key就是都不同的,hash值真就一样,hash拆分不了,这个情况下,如何处理?

因为足够小了,我觉得无论是用某种省内存的方式先全局排序,还是等大小切分,分别word count处多个map,然后merge map(由于假设的条件,整个分片的map是放不了内存的,可能得一次只处理部分key,存入硬盘,再做下一组),都是可以的。

对于常规分片,就可以做常规处理。就和快速排序一样,我们还是相信大概率的,最坏情况总是少的。

分布式锁

分布式锁不是说锁是分布式的结构。是说如果系统是分布式的,进程间(无论是同一主机上还是多主机)要保证某些资源的独占,就得有个外部工具帮忙来指挥。硬要系统内部做,不引入外部工具,也不是不行,但是从工程角度来看,肯定是直接利用现成工具更好,而且也减轻代码复杂度。(虽然大家经常先用zookeeper,随后又嫌弃它,又移除zookeeper。但前期还是需要zookeeper,毕竟搭建快。)

常见分布式锁就是zookeeper和redis。

具体看算法总结 BigData Algo。这里后续贴一点常见的具体面试题吧。

CAP定理 theorem/principle

我的记忆中一直是都满足P,只是看CP还是AP。有一天朋友跟我说有CA,我就混乱了。所以来重新学一遍CAP吧。

注意大前提:分布式系统, a distributed system with replication。

  • Consistency (C): Either get the most recently written data or get an error for each read
  • Availability (A): Every request can get a (non-error) response, but there is no guarantee that the latest written data will be returned.
  • Partition fault tolerance (P): Although any number of messages are lost (or delayed) by the network between nodes, the system continues to operate

consistency一致性很好理解,分布式多副本,就需要考虑读写同一个分片的不同副本会不会有问题。强一致性协议的系统,当然是保证C的,例如paxos,raft。弱一致性协议当然不保证C,例如gossip。需要注意,强一致也不是说每次read都能读到有效数据,如果副本异常,你报错,那也是合理的。起码不会读出更早的数据,用户会当作自己读到的是最新的数据。

availability可用性,很多强一致性系统也说自己高可用,但其实也没那么高可用。这个A说的可用,并没有特指什么东西。系统可以更随便,比如,就是只要能返回给你数据就行,旧的我也给;又或者,系统给你回复,但是很慢很慢才回,但我也没拒绝你的request,也没报错,你也不能说我不A。

partition tolerance分区容忍最难懂,因为如果分区,有中心的系统找不到中心的一个分区必然就挂了,这么说好像就都没有P了。
P是指网络不可信了,信息会丢,paxos/raft等协议也提到过这个概念。

很多地方将CAP三个东西分开讲,也有人说某某系统是CA的,说的好像三个任选其二。但下面这个理论我觉得更有道理一点:

1
Possibility of Partitions => Not (C and A)

只要你会面临分区的问题,你就无法同时保证C和A。而不是去讲那个系统能保证P。如果你非要讨论单机系统,那当然没有分区问题,它CA都满足,那当然可以。(但我们的讨论前提是分布式)

环境note

About OS

macOS

homebrew

默认源太卡,容易install失败。换tuna的源,设置方法见

homebrewhomebrew-bottles。(注意,这两个都要配置)

gdb/lldb

mac中使用lldb,不需要指定bin。lldb -c /cores/xxx即可。

linux

debian源

debian | 镜像站使用帮助 | 清华大学开源软件镜像站 | Tsinghua Open Source Mirror

apt update

apt install maven

Windows

Windows下的终端Terminal,如何加入snippet,参考https://github.com/microsoft/terminal/issues/6412#issuecomment-964343941。我个人常为OpenMLDB加的snippet:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
{
"command": {
"action": "sendInput",
"input": "set @@execute_mode='online';\r"
},
"name": "on"
},
{
"command": {
"action": "sendInput",
"input": "set @@execute_mode='offline';\r"
},
"name": "off"
},
{
"command": {
"action": "sendInput",
"input": "set @@sync_job=true;\r"
},
"name": "sync"
},
{
"command": {
"action": "sendInput",
"input": ":set mouse-=a\r"
},
"name": "mouse"
}

不需要快捷键,Ctrl+Shift+P可以根据name快速选择,快捷键还懒得记。

vim

vim时用鼠标选择一段文本,可能进入VISUAL模式。VISUAL模式下的复制/粘贴/剪切得用y,p,d。注意,VISUAL模式下复制的文本,不会记录在剪贴板,只能在vim中使用,拷贝不出去。

更习惯不进入VISUAL模式的话,set mouse-=a。更改默认配置,把这个设置放在~/.vimrc里。

python

python源也可以用tuna的,可以直接

1
pip install -i https://pypi.tuna.tsinghua.edu.cn/simple <pkg>

全局配置方法

1
2
python -m pip install --upgrade pip
pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple

详情见pypi | 镜像站使用帮助 | 清华大学开源软件镜像站 | Tsinghua Open Source Mirror

sh

git

move or remove them before you merge, use this to get related files

1
git xxx 2>&1|grep -E '^\s'|cut -f2-|xargs -I {} echo "{}"

ps and network

no ps需要apt install procps

1
2
ps axu | grep xx | awk '{print $2}' | xargs kill -9
netstat -ltnup

run bins

运行当前目录下所有文件,测试时常用。

1
for f in *; do ./$f; done

top record

记录一段时间进程cpu mem等资源的变化,适合抓出程序最大占用内存的时间点。

1
2
3
while true; do sleep 10 && top -b -p 25440 -n1 | tail -1 ; done >> process.top
# add time
while true; do sleep 10 && date && top -b -p 25440 -n1 | tail -1 ; done >> process.top

npm

如果目的是pnpm,不用单独下载npm,直接下载pnpm就行。 https://pnpm.io/zh/installation

如果是npm,下载nvm更合适管理node版本。

keyboard

rk61键盘配合mac使用,支持很差,还是需要改键。使用karabiner-element做改键。

可以直接修改~/.config/karabiner/karabiner.json,注意里面的device是有vendor id和product id的,得填对,可以用karabiner直接查到。

windows下改键,使用PowerToys的键盘管理器。

docker源

就是registry-mirrors这个配置项。linux直接在 /etc/docker/daemon.json里改,mac可以在docker desktop设置里找到配置文件,也是一样的修改。

1
2
3
4
5
6
7
{
"registry-mirrors": [
"http://hub-mirror.c.163.com",
"https://docker.mirrors.ustc.edu.cn",
"https://registry.docker-cn.com"
]
}

重启后docker info可以查看。

tmux

tmux如果另一个没退,就又attach,可能分辨率会用另一个的,就会右边下边出现很多点点。tmux命令choose-client,选择分辨率,选小的就能撑满屏幕。

默认choose-client是shift-b,但我改了tmux的prefix,所以是ctrl-a,再shift-d。自己查自己tmux的keys,用choose-client bind的key。

debug

oom

怀疑进程被oom kill了,需要查日志,dmesg -T没有权限要求,但它的时间不准确,甚至可能是未来时间。最好查/var/log/messages,但它可能需要root权限。
Docker内的进程也是被物理机管理的,如果占满内存,也是会被kill,而且在物理机上会有日志。

Grokking the Coding Interview: Patterns for Coding Questions

课程原地址

题目目录与答案 Python版——完整题目目录,包含challenge。

题目目录与答案 C++版(附题目OJ的地址)——推荐用OJ测试自己的算法,但是这个repo里基本没有challenge题目。

1. Pattern: Sliding Window

Maximum Sum Subarray of Size K (easy) – GeeksforGeeks

k长的连续子序列,使该子序列和最大,这个和为output。
窗口长度都固定了,只需在遍历一遍时加后一个减前一个就行了,O(n)。

Smallest Subarray with a given sum (medium) – LeetCode

注意读题,nums都是正整数,target也是正整数。这个条件大概率需要用到。

假设有一段连续子序列了,它已经>=target了,就不需要再继续加入元素了(序列外右边第一个元素),因为再加的话size就变大了。而这个size可以向右横移一格,可以立马算出新的sum(这里很节约时间,降低算法复杂度)。

这个sum如果 >= target,那么它就有机会再小一点,而这一次需要的是减去序列内的第一个元素,可以理解为“收缩窗口”。
这个sum如果 < target,那就可以再向右横移了,因为size扩大,对结果没有任何帮助。

总体看下来,只要横移和收缩,算法复杂度是O(n)。

初步思路

初步思路是,先算个nums[0…x]之和 >= target的窗口,然后这个窗口开始向右移并尝试收缩。但其实不对,因为第一个窗口,不是非得从第0个元素开始,这个窗口自身就应该尝试收缩。这个逻辑补上后是能ac的。
不过,初步思路翻译为代码,还是有些小坑,肉眼很难查。建议背一个滑动窗口模板。

滑动窗口模板思路

此思路最核心的思想就是,不强求窗口横移,反正先向右扩展1个,再左边收缩一个,就达到了横移。
因此滑动窗口的头尾都可以移动,而且是分别移动,用两个变量来表示,(start,end)。
因为收缩(start向右移)和扩展(end向右移)可以各做各的,所以没必要先找到一个总和 >= target的初始窗口了。

所以,步骤可以化简为,每一次都扩展1下,然后尽力收缩(while不定次收缩)。

1
2
3
4
5
6
7
while end < n:
tmp_sum += nums[end]
while tmp_sum >= target:
win_size = min(win_size, end-start+1)
tmp_sum -= nums[start]
start += 1
end += 1

至于几个变量的初始值,现场推理一下也可以得到,不做赘述。
P.S. win_size没必要用int的max,用len(nums)+1就可以了,反正都是不可能的值。

补充

滑动窗口的算法,nums就算有负数也无所谓。(被烟雾弹迷惑🐶)这个条件大概是为“前缀和”这一方法准备的。

前缀和方法,简单来讲就是nums[0..i]之和组成一个sum数组,这个数组严格递增,都不存在相等的元素。

然后就可以用二分来找了(看到有序就要想到二分),当前sum[i]为0到i元素的和,sum[j] - sum[i] >= target转换为sum[j]>=target+sum[i],那么就是在sum数组里找target+sum[i]的lower bound。

为什么不是以j为结尾,sum[j] - sum[x] >= target转换成sum[x] <= sum[j]-target,找sum[x]呢?因为是upper bound(第一个>某值的元素)的前一个(必定<=某值),不如直接找lower bound简洁,而且不用处理减出负数的情况。

Longest Substring with K Distinct Characters (medium, google) – LintCode

Given a string, find the length of the longest substring in it with no more than K distinct characters.

也就是LeetCode340题 Longest Substring at Most K Distinct Characters。

这道题输出是最长子串的长度,所以显然可以套用滑动窗口的模板。和上一个题目一样,都是扩展会使得条件不符,要通过收缩来满足条件。
即,窗口扩展一旦字符种类超过k,就可以通过窗口收缩来削减字符种类。
唯一不同的点是:update longest string的时机(其实就是用end-start+1来尝试更新记录的longest)
因为while distinct_count(dict) > k时是去收缩,break while时说明distinct_count(dict) 已经<=k了,这个时候的[start, end]才是一个可能解,符合条件的可能解。
所以update longest string len是在while循环外,而且是之后。
至于如何实现dict和distinct_count,随便吧,简单也好,高效也好。

P.S.注意到了吗,这个题目和前面一题Smallest Subarray的区别?

看不出来也正常,我做了几道题才突然回头发现的😂而且明明之前做过笔记,重蹈覆撤🙄️

果然人类的本质都是复读机

这个题已经开始了longest之路,也就是说,只是想求一个最长的长度,根本不在意“重复无意义的更新”。

按之前的模板,此题的伪代码应写为:

1
2
3
4
5
6
7
8
9
while end < n:
dic[s[end]]+=1
while len(dic)>k:
dic[s[start]]-=1
if dic[s[start]]==0:
del dic[s[start]]
start+=1
longest = max(longest, end-start+1)
end+=1

这样的写法,保证了longest变量更新时,当前窗口都是len(dic)<=k的,也就是合法的情况。但确实可能会出现窗口被收缩的很小的时候(为了合法),此时max更新也是白干的(longest还是原值)。

而如果代码将内层while变为if:

1
2
3
4
5
6
7
8
9
while end < n:
dic[s[end]]+=1
if len(dic)>k:
dic[s[start]]-=1
if dic[s[start]]==0:
del dic[s[start]]
start+=1
longest = max(longest, end-start+1)
end+=1

自然,在longest更新时,当前窗口可能都还满足条件,不合法,但仍旧去做了一次longest的更新。但从数值上来讲,由于不满足条件会被收缩一次,加上前面的end扩展一次,窗口等于做了一次平移。那么end=start+1的值就不会变大,longest的更新自然也是不会有实际作用的。

这一个改动,它到底好在了哪里呢?光看经过,start,end两个游标都是单向前进的,2-while和while-if都不会使两个游标左右飘。但由于2-while会保证每次窗口都是合法的,和while-if相比,start这个游标可能会更靠右。举个极端例子,如果longest是[0, n-2]这个窗口,下一次扩展end就会到末尾n-1,while-if此时发现窗口不合法,收缩一次,就溜了。而2-while,会愣是要求[start, n-1]窗口合法,可能start从0不断右移,直到n-1才停止。无用操作在这个例子就占了一半。具体例子就是AAAAB, k =1。

这个优化并不容易读,我也不建议平时代码搞这么tricky。在这个题目里,仅仅在节省len查询和dict更新,都是较高效的操作,而且有次数上限(由于start标最多都走到末尾,操作次数最多n次)。优化效果仅仅锦上添花。但这个将while改为if,在其他题目中可能有奇效。因为while的判断条件如果复杂度较高,这里的收益就很大了,见Longest Substring with Same Letters after Replacement一题。

Fruits into Baskets (medium) – LeetCode

跟上题一模一样。题目暗示着连续区间,就可以尝试滑动窗口方法。

但这一题case比上一题的规模大,最简单的dict实现(不及时删除value为0的项,每次都要遍历得到distinct count)这种方法就超时了。还是推荐及时删除value为0的项,其实比不删还简单,因为删除这个操作只会在收缩时出现(只有此处count–)。

可优化为while-if。

No-repeat Substring (medium) – LeetCode

和前面的题目毫无差别。

但注意第二层while的判断条件是什么,如果判断条件是“窗口dict的value全为1”,那么你可以改出while-if。

如果是“当前字符c的count>1”,就改不了了。因为现在只看当前字符,那就意味着窗口必须是合法的,然后扩展,加入当前字符。while-if便不可用。

“只看当前字符”这种方法的优化思路是,如果start要跳,直接让start去“当前字符上一次出现的位置的右边”。 因为前面的字符都可以跳过了。还可以再化简代码,但会很难读,所以还是适合而止吧。

Longest Substring with Same Letters after Replacement (medium, amazon) – GeeksforGeeks

We have a string s of length n, which consist only UPPERCASE characters and we have a number k (always less than n and greater than 0). We can make at most k changes in our string such that we can get a sub-string of maximum length which have all same characters.

Example 1:

1
2
3
Input: s = "ABAB", k = 2
Output: 4
Explanation: Change 2 'B' into 'A'.

Example:

1
2
3
Input: s = "ABCD", k = 1
Output: 2
Explanation: Change one 'B' into 'A'.

这种要替换字符的题目,往往不用真的替换,只要数值上达到某个条件就行了。比如这个题,不用想着应该替换哪些字符,而应该想“总字符个数 - 不需要被替换的字符个数 >= k”就行了。很容易想到,“不需要被替换的字符个数”就是区间内个数最多的那个字符,这样,总字符数才能多一点(对某个区间而言,不需要理会那些无意义的可能解,“保留频率最高的字符,把其他的替换为该字符”肯定是操作数最少的)。于是,替不替换的问题就化简为简单的统计问题。

统计问题虽然简单,但是复杂度略高。想要快速,可能需要两个map,ch->count, count->ch。所以,while-if就很适合了,由于只收缩一次,max_count就从dict里统计一次就好了,不用强求更快速。

Longest Subarray with Ones after Replacement (medium) – GeeksforGeeks

比上题更简单,只需要一个int变量就能描述一个窗口内的0的个数。

Problem Challenge 1 - Permutation in a String (hard)

排列,不允许多一个字符,所以窗口大小是固定的,滑动用来节省“更新窗口属性的代价”。题目如果对字符多加限制,比如此题限制只有小写字母,也就只有26个可能,其实描述窗口用长26的数组都可以,不用非要用counter。用counter有个麻烦点在于当某个字符的count为0时,你需要删掉它,不然就没法和s1的counter比大小。如果两个字符串的counter都先把26个字母的空间开出来,那还不如数组节省空间。

Problem Challenge 2 - String Anagrams (hard)

和challenge1没有区别

Problem Challenge 3 - Smallest Window containing Substring (hard) *

没啥特别,就是用collections.Counter()减法,比自己写的比较函数要慢不少,1200ms vs 500ms。

Problem Challenge 4 - Words Concatenation (hard)

注意读题!words长度相同!匆忙读题后,我还以为要处理不同的切割方式,以为会出现“一个substring里有几种正确的word排列”。那这题可能不止hard了。

可以简单想到的办法就是窗口从0一直滑到尾,step为1,每一次窗口都得重新计算下word-count,然后和words参数比较。

显然这个没有什么巧妙点,没有节约计算量,所以想要利用滑动窗口,当然得想点骚方法。其实就是,每一次都设置一个起点,从这个起点开始,只会按词长来拓展,这样就能像字符型的题目一样,充分利用滑动窗口的扩展收缩,减少计算。举例说明,就是一个单词长度为len,s串总长为n的话,第一趟是从0开始一个len切一刀,这样切割后的串扩展和收缩都是一个词的,第二趟就是从1开始切,以此类推,最后一次是len-1开始切。可以想到,这样也是把所有可能性都考虑到了。

改进后的滑动窗口方法效果是明显的,1000ms到100ms。

Additional

Sliding Window Maximum (hard) – LeetCode

这个题跟滑动窗口模板毫无关系,窗口大小都固定牢了。唯一的问题点在于用什么结构体来提炼窗口信息,既能很快查到最大值,又能在窗口滑动时很快更新好(滑动本质就是加入一个数,去掉一个数)。快速查到最大值,可以想到利用堆。但是堆有一个明显问题,就是它不适合去删除内部的某个元素(不是堆首)。而“去掉一个数”这个操作很可能就是去删除某个中间的值。堆的删除操作是个不太ok的操作,因为这个堆是简单的堆(并非压平了看,完全有序的,不是堆排序之后的结果),也就是删除操作不能快速定位到要删除的元素。

但是别直接放弃堆(我就放弃了,想别的方法,走远了)。这时候,尝试多推导一下,就会发现,不用着急删除元素。因为元素可以跟上自己的位置idx,如果堆顶拿到的idx已经不在窗口内,再删除也不迟。也就是延迟了删除操作,正好还让删除操作符合堆的操作习惯,只删堆顶。

到这里,起码算是题目做出来了,而且这个方法也不差,能交差。

但这道题更想考的点是别的,所以还需要再优化。说是优化,不如说是换了解法。从堆想到单调队列,反正我是做不到。。。

单调队列是什么东西?先记住它能够动态地维护定长序列中的最值。所以只要定长、最值,就可以想到尝试单调队列。具体来讲,单调队列是,push元素前会把前面的元素都从后往前访问一遍(遇到比自己大的停止),比当前元素小的都删除,再将元素放在队尾。其实,全访问也没关系,最后结果没有差别,不过,遇到比自己大的就停止可以节省点时间。最后的结果就是队列里头到尾是从大到小有序的。

举例说明(为了多点情况,和leetcode原例子有差别):

1
nums = [1,3,-1,-3,-2,3,6,7], k = 3

单调队列应该长什么样?

[1,3,-1]初始窗口入队列后,q=[3,-1],那么这个窗口的最值就是3;

[3,-1,-3], q=[3,-1,-3],最值还是3;

[-1,-3,-2], q=[3,-1,-2],最值3,但3已经不在窗口内,所以pop,q=[-1,-2],最值-1;

[-3,-2,3], q=[3],最值3;

[5,3,6], q=[6],最值6;

[3,6,7], q=[7],最值7。

可以看到,只用进行简单比较,就能维持着最值,比堆的时间复杂度低。

单调队列它为什么做到了呢?

可以这么想,首先简单起见,只考虑窗口内元素的单调队列情况,比如3,1,2序列,k=3,很明显1会在2进入时被扔掉,因为2进入了之后,怎么也是2比1晚出窗口,就算可能是最大值,也是2可能,1是完全没有可能竞争最大值的。

再考虑队列前部还有窗口外元素的情况,窗口外的元素还留在队列里(可以称为过期元素),也就是说窗口内的单调队列最大值(也就是队列列首)比过期元素小。但这并不影响窗口元素那部分的选择,无论有没有过期元素,窗口那部分的单调队列都长一个样。所以过期元素可以简单地pop出来扔掉。

总的来说,单调队列就是剔除了“必然不能争最大值”的那部分无用值。单调队列实现上没有什么难度,不再赘述。

P.S. Python实现遇到超时问题,因为我没有用pop,而是选择到一个点,取出切片[:x],然后又append。具体细节待调查。从耗时来看,很明显这一连串操作应该搞出了deep copy之类的耗时操作。都叫单调队列了,就好好用python collections里的deque。

Shortest Subarray with Sum at Least K (hard) – LeetCode

仔细读题,这个题的数组里有负数。滑动窗口的算法模板是没办法直接套用的,因为窗口收缩的停止条件是sum < k,由于有负数,你不能在窗口sum < k时停止,你必须继续收缩,否则就可能错过解。

停留在滑动窗口算法模板这个框架里,是没办法找到解法的。

这时候只能躺平

只能说先回答个O(n^2)的解法吧,聊胜于无。很明显,所有区间和都是可能的最佳答案,所以前缀和加二级遍历所有区间,能得到答案。

当然可以优化,但是很难凭空想出来参考答案“单调队列”。接下来的说明是以知道“前缀和+单调队列是较好解法”为前提来看这个题目,所以没有什么顺理成章推导,全凭参考答案提示。

单调队列维护什么?

题目核心是求最短的合法子串,所以短是最核心的,和只要>=k什么都行。那么假设当前看i,以i开头的最佳子串,当然是从i开始寻找第一个j,能使sum[i..j]>k(前缀和数组里两个数减一下就好了)。j之后的都不用找了,没有比i到j更短的了。那么反过来思考,从i到j,中间可能有多个值,一一遍历就是暴力解法,优化当然是更快找到j,那么如何迅速定位到j呢?

或者换个说法,i到j中间的多个可能性,有哪些是必然不可能的,可以直接过滤?答案是没得😛。因为如果从[i, n)区间提取出单调递增的部分,可以使用二分,但这个题应该用不着。那这个思路还有什么优势呢?我们一一遍历到j,然后得到这个i的最优解,然后i++,然后你还是得遍历过去,因为单调递增队列本身不能有更多的改动,最多把队列头比i小的pop出来。那换个角度,递增队列是不是能从后往前看,当某个靠后的j满足了条件,你能把它怎么样?你不能动它,因为接下来的i(更大的i)和j一起也能满足条件,这个窗口长度肯定比现在的短,你删了这个j,就丢掉了解。

那么就应该考虑换个顺序,以当前j为标杆,去找前面的i,它有个什么好处呢?当你从前往后找i,某个i满足条件后,你可以大胆删了它,因为j会++,当前的i充分发挥价值(用来更新window len)后,就不需要了,后面的j和这个i组合是无意义的。

P.S. 我想到了以j找i,但又想的是从j-1往0这个方向找,想想看,跟以i找j没什么差别,它们跟暴力解法比,完全不是稳定降低复杂度,甚至可以劣到没区别。(于是Python实现也完美超时了)

总的看“前缀和+单调队列”,大概的最好状态是每次O(1),然后n个j,所以O(n),比如,每次看队列头d[0]的preSum都很大,不能让preSum[j]-preSum[d[0]]>=k,单调递增的d后面的元素也不能满足要求了,所以每次就立马完成。最差的状态可能是某次j,从0到j遍历,每次都符合条件,都要pop并更新window len。但很明显,队列最多跟n一样长,而且pop出去了又不会回来,所以次数最多n次,并不会膨胀。所以总的复杂度还是O(n)的。

这道题还是很难的,难在拼出正确的方法,知道方法后,代码实现不难。

Max Consecutive Ones III

突如其来的一道复习题。while-if即可,虽然和while-while实测没什么区别,大概是case的原因。

Replace the Substring for Balanced String (medium) – LeetCode

题目是说要替换的字符包含在一个子串里,要求子串最短,子串符合某个条件即可,那就很适合套用滑动窗口模板了。

Count Number of Nice Subarrays (medium) – LeetCode

这题一看就不适合立马套用模板,扩展和收缩求最长最短很有效,但这里没有用处。举例说明,当我们找到一个窗口恰好有k个奇数,此时可以滑动窗口么?当然不能。所以放弃吧。然后考虑到奇数是核心,先找到k个奇数的最小可能,它的左右两边如果分别有a个偶数和b个偶数,那么这里就有很多个子串可能,1+a+b+a*b。而找奇数,可以直接抽出奇数,这样奇数数组里每k个就是一个base,延展下两边的偶数。既没有重复也不会漏算。

滑动窗口完结撒花🎉