最新文章

NTP 网络时间协议

NTP 网络时间协议

By KSkun, 2020/7/20 NTP(Network Time P 

QUIC 快速 UDP 互联网连接协议

QUIC 快速 UDP 互联网连接协议

KSkun 2020/6/12

2013 年,Google 提出了快速 UDP 互联网连接协议(QUIC),实现了一种可以替代 TLS/TCP 模式的传输协议,并且将其应用在 Google 搜索、YouTuBe、Chrome 浏览器等产品上。在这些产品上收集到的数据表明,QUIC 相较于传统的 TLS/TCP 模式具有较大的性能优势。2015 年,QUIC 被提交至 IETF,随后在 2018 年被确定为 HTTP/3 标准中的内容,正式纳入 HTTP 家族。

为什么 Google 要开发 QUIC?为什么选择了 UDP?QUIC 如何优化性能?QUIC 效果如何?接下来,我们将一一解答这些问题,并阐释 QUIC 的基本内容。

现在的 HTTPS 如何工作?

要了解 QUIC 的开发背景,首先需要知道今天的 HTTP 以什么形式工作。因此,我们将介绍 HTTPS 协议栈及其存在的问题,从而引出 QUIC 的优化。

传统 HTTPS 协议栈

传统的 HTTPS 协议栈包含以下三部分:

  • 应用层:HTTP/2,提供 HTTP 应用功能,由客户端(浏览器)在用户空间实现;
  • 安全层:TLS,提供安全的传输链路,由客户端(浏览器)在用户空间实现;
  • 传输层:TCP,提供可靠传输方式,由操作系统在内核中实现。

在最近的几年中,上述几种协议均进行了许多改进,例如:

  • HTTP/2 解决了 HTTP/1.1 的性能问题,对 HTTP 头进行压缩,引入了 Server Push(服务器主动推送流,而不是等客户端发送请求后再响应,减少了网页加载多次请求资源的性能问题),在单个 TCP 连接里实现多路复用请求等;
  • TLS 1.3 也优化了 TLS 1.2 存在的一些问题,包括废弃了过时的加密算法,支持 0-RTT 传输(优化握手时间)等;
  • TCP 中,CUBIC、BBR 等优秀的拥塞控制算法提高了网络资源的利用率,优化了传输效率。

容易发现,HTTP 与 TLS 都进行了较大规模的改动,而 TCP 并没有对其协议本身进行修订,这一现象是存在原因的。

「僵化」与「耦合」

在 QUIC 的论文中,Google 使用了僵化(ossification)一词来形容现在的互联网中间设备。这是因为,许多设备供应商在设备中预置了一些规则,如防火墙会禁止已有规则之外的传输,NAT 会以一定规则重写传输头等。如果改动 TCP 协议,这些设备必须更新规则才能支持变动,设备更新的不及时则会影响修订的推进。

此外,由于 TCP 通常实现在操作系统内核中,这种 TCP 与操作系统的耦合(coupling)导致如果修改 TCP 协议,则必须升级操作系统内核,而内核的改动与升级的下发的时间周期很长,也影响了修订的推进。

协议本身存在的问题

由于 TLS 是在 TCP 建立连接后再进行握手,除了 TCP 建立连接时的 1-RTT 时间开销,TLS 还增加了一个 2-RTT 的握手环节。Web 连接大多都是短暂的连接,频繁进行不必要的重复握手对短传输性能存在影响。

QUIC8 - QUIC 快速 UDP 互联网连接协议
图 1:TLS 握手的流程

在 HTTP/1.1 和 HTTP/2 中,协议限制了最大 TCP 连接数量,而 HTTP/2 使用了多路复用同一 TCP 连接的技术,这带来了新的问题:行头阻塞(head-of-line blocking)。当复用同一 TCP 连接时,由于要求响应按顺序传输,第一个响应丢包重传时,会阻塞后面耗时较短的响应及时传输。

QUIC9 - QUIC 快速 UDP 互联网连接协议
图 2:行头阻塞的示意图

QUIC 是什么?

广泛使用的 TLS/TCP 仍存在很大的改进空间,但由于种种困难无法推行。为了彻底解决 TCP 带来的麻烦,Google 选择了 UDP,继而创造出了本文的主角——QUIC。

QUIC(Quick UDP Internet Connection,快速 UDP 互联网连接协议)是一种以 UDP 为底层传输协议,支持加密、多路复用,工作在用户空间的的低延迟传输协议。

QUIC1 1024x512 - QUIC 快速 UDP 互联网连接协议
图 3:QUIC 相对于传统 HTTPS 协议栈的地位

如图所示,QUIC 取代了传统 HTTPS 协议栈中部分 TCP、TLS 与部分 HTTP/2 的位置,向下使用 UDP 进行传输,向上提供 HTTP 接口。QUIC 具有与 HTTPS 相同的接口与安全特性,且低延迟、高效率、能够进行快速的迭代更新,有效解决了上面所提到了诸多问题。

接下来,我们通过 Google 提供的实验数据了解 QUIC 优化传输效率的效果。Google 通过握手延迟、搜索延迟(在 Google 搜索引擎上实验)、视频延迟、重缓冲率(视频暂停缓冲的时间占总时间的比例,皆在 YouTuBe 上实验)来考察 QUIC 对优化不同应用场景性能的效果。

QUIC2 1024x384 - QUIC 快速 UDP 互联网连接协议
图 4:在不同 RTT 链路情况中,各种协议的握手延迟
QUIC3 1024x384 - QUIC 快速 UDP 互联网连接协议
图 5:在不同 RTT 链路情况中,各种协议的搜索延迟
QUIC4 1024x384 - QUIC 快速 UDP 互联网连接协议
图 6:在不同 RTT 链路情况中,各种协议的视频延迟
QUIC5 1024x384 - QUIC 快速 UDP 互联网连接协议
图 7:在不同 RTT 链路情况中,各种协议的视频重缓冲率

可以发现,在各种情况下,QUIC 的性能不同程度优于 TCP/TLS 的表现。

QUIC 如何工作?

QUIC 是如何实现优秀的性能的?要解答这个问题,就必须理解 QUIC 的工作机制,并将其与 TLS/TCP 模式对比,从而体现 QUIC 的优势。接下来,我们将简要介绍 QUIC 的几个核心机制。

建立连接

QUIC6 - QUIC 快速 UDP 互联网连接协议
图 8:三种不同的 QUIC 的握手情形

上图描述了三种 QUIC 的握手情形,接下来分情况研究这三种情形。

初始握手:第一次握手之前,客户端并不知道任何有关服务器的信息,因此客户端发送一个不完整的客户端问候(CHLO)信息,服务器回应一个拒绝(REJ)信息。服务器回应的 REJ 中包含了服务器的配置细节(包含服务器长期公钥)、证书链、签名、源地址令牌。源地址令牌包含了客户端的公共 IP 地址与时间戳,客户端在接下来的信息中包含这一令牌来代表自己的身份。客户端通过签名来验证服务器发送的 REJ 信息,并将使用这些信息发送完整的 CHLO(包含客户端的临时公钥)。

最终握手(或重复握手):初始握手使客户端获得了与服务器建立连接的所有细节,通过发送完整的 CHLO 信息与服务器成功建立连接。此时客户端使用的密钥为初始密钥,包含客户端的临时私钥和服务器的长期公钥。同时,为了达到 0-RTT 的握手延迟,客户端会同时开始使用已知信息发送数据,而非等待服务器回应后再发送。

如果握手成功,服务器回复一个服务器问候(SHLO)信息,这个信息使用初始密钥加密后发送,包含了服务器的临时公钥。客户端收到 SHLO 后,将切换使用服务器的临时公钥加密。这样一来,除了客户端最初的数据使用初始密钥加密,其他数据都使用临时密钥加密,实现了数据的前向安全性(即使服务器长期密钥泄露,也无法使之前的数据加密失效)。

客户端会缓存服务器配置与源地址令牌,在下一次连接同一服务器时,直接以缓存的配置与服务器进行握手,实现 0-RTT 握手。

客户端缓存的配置或令牌有可能过期,此时服务器会直接发送 REJ 来拒绝握手,并附带最新的信息。客户端从 REJ 中提取最新信息,更新缓存后即可再发起握手。

版本协商:客户端会首先提议一个版本,并以该版本传输数据。如果服务器不支持客户端提议的版本,则发回一个强制版本协商数据,包含了服务器支持的所有版本。如果服务器的版本总是高于客户端版本,则版本协商是 0-RTT 的,因此鼓励服务器及时更新 QUIC 版本。为了防止降级攻击,生成密钥的时候将把客户端与服务器发送的版本信息也纳入参数之中,避免信息被修改。

多路复用

为了避免产生行头阻塞的问题,QUIC 支持在同一连接中同时传输多个流,丢包只影响到涉及到的流,而其他流仍然能正常传输而不被阻塞。

QUIC 流是可靠的双向字节流,单个流最高可以传输 264 字节数据。流非常轻量,因此发送少量数据时也可以使用新流。流通过流 ID 进行标识,为了避免 ID 冲突,客户端发起的流 ID 为奇数,服务器为偶数。流可以在发送数据时隐式发起,通过在最后一个流帧中设置 FIN 标记关闭。如果流不再需要,可以取消流而不断开 QUIC 连接。

QUIC 数据包的格式如下,一个数据包包括公共头与多个流帧,每个流帧具有自己的头与数据,数据包可以承载来自不同流的流帧。

QUIC7 1024x673 - QUIC 快速 UDP 互联网连接协议
图 9:QUIC 数据包的结构

QUIC 发送数据的速率是受限的,因此必须决定如何在多个流之间分配速率。在论文实现中,QUIC 使用 HTTP/2 流优先级来分配速率。

身份验证和加密

QUIC 数据包中未加密的部分(包头等)对路由或解密数据都是必要的。Flags 编码了连接 ID 与包编号的长度;连接 ID 可以被负载均衡使用来分配服务器或被服务器用于识别连接状态;版本号和多样化 nonce 只出现在较早的包中,服务器生成 nonce 为客户端生成密钥增加熵值。两方都以包编号作为每个包的 nonce,用于进行加密与解密。

在未加密的握手包(如版本协商信息)中包含的信息都被用于生成最终密钥,篡改这些包会导致生成的密钥不一致,从而导致连接失败。当服务器中不存在连接状态时,会发出重置包,通常路由更改或服务器重启会造成这种情况。由于服务器不知道连接的密钥,重置包只能不加密且不经验证。

丢包恢复

由于 TCP 重传时,ACK 包的编号与原始编号相同,ACK 接受者无法判断确认的是重传还是原始包,必须等待 1-RTT 才能确认这一信息。每个 QUIC 包都有不同的编号,重传包也是一样,因此不需要区分是原始还是重传包。在 QUIC 包中,包编号表示时间顺序,而流帧中的偏移量表示数据的顺序,这些信息可以更准确地检测丢包。

QUIC ACK 包含了接受数据与发送 ACK 之间的延迟,包含这一信息有助于更准确地估计 RTT,为 BBR 等拥塞控制算法提供帮助。

流量控制

QUIC 同时限制了单个流可以消耗的缓冲区大小与一个连接的缓冲区大小,避免了流与连接之间的行头阻塞。

QUIC 接收方会首先发送每个流中的缓冲窗口大小,并定期在流中发送窗口更新帧来扩大窗口大小限制。连接级窗口也用相似的方法控制。此外,实现还采用了类似 TCP 中的调整。

拥塞控制

QUIC 不依赖于某一特定拥塞控制算法,而是提供控制接口,支持多种算法。

连接迁移

QUIC 连接由一个 64 位的连接 ID 标识,该 ID 可以保存连接的状态。当客户端连接发生迁移时,可以通过连接 ID 来迁移连接的状态,避免 NAT 更改丢失状态的问题。

QUIC 发现

客户端并不知道服务器是否支持 QUIC,因此客户端第一次发送 HTTP 请求时先使用 TLS/TCP,而服务器在响应中附带一个 Alt-Svc 头来说明支持 QUIC。

在后序请求中,客户端争用 QUIC 和 TLS/TCP,但尝试 QUIC 略微超前 TLS/TCP。如果 QUIC 无法连接,则客户端之后使用 TLS/TCP。

QUIC 的局限性

尽管在实验中表现优秀,QUIC 仍然具有其局限性。Google 在其论文中提到了以下几点问题:

超前连接:应用程序通过提前进行握手来避免握手造成的延迟,这种情况下 QUIC 的 0-RTT 握手并不具有太大的优势。

高带宽、低延迟、低丢包率的网络:在这样的网络中使用 QUIC 几乎没有优势,反而有可能带来性能损失。

移动设备:QUIC 在移动设备中优势也不大,通常是因为移动设备应用针对其网络特点进行了优化。而 QUIC 需要较多的 CPU 性能,在移动设备上表现并不优。

UDP 限制:部分网络会限制或禁用 UDP 流量,QUIC 无法建立连接。

网络中间设备的僵化:QUIC 是一种快速迭代的协议,中间设备有时会附带对 QUIC 连接的限制规则,当 QUIC 更新时设备规则并未及时更新,从而影响到 QUIC 的正常连接。

CPU 占用较高:QUIC 的 CPU 使用率约是 TLS/TCP 的 3.5 倍。Google 通过优化加密算法等方式降低 CPU 使用率至 TLS/TCP 的 2 倍,但仍然较高。

结语

Google 对互联网技术发展做出的贡献巨大,相继创造了 BBR 拥塞控制算法、QUIC 协议等更高效的互联网技术,并推动它们成为现代互联网基础设施的一部分。

截至目前,QUIC 还未得到大规模的部署与使用。我们期待着 QUIC 能够在未来的互联网中充分发挥其性能优势,在互联网中创造更多可能性;同时也期待着更多优秀的互联网技术被创造、被接纳、被广泛使用,让世界更加触手可及。

参考资料

本文引用的图片部分来源于论文或互联网,引用来源均列入参考资料列表中,感谢原作者的分享。

BREAK IT, CHANGE IT

BREAK IT, CHANGE IT

清流真可爱!

Case 1

昨天晚上 20 点,我在补白天翘掉了的大学物理课,但是非常困,感觉完全听不进去课,于是决定躺床上休息 15 分钟。

然而,我再睁眼的时候已经是第二天 0 点了,此时的我还穿着前一天白天出去买菜的衣服,没有洗漱,随意地躺在床上。我本想就这么睡过去,第二天早起再洗漱换衣服。

其结果是,我并没能睡着,反而非常精神。翻来覆去折腾了 1h,最终放弃睡觉,开始玩手机。就这么一直玩完了躺着,躺够了又开始玩,遍历了手机上每一个有我感兴趣内容的应用,实在无聊至极。

由于最开始的策略是想尽量睡觉,加上不想打扰到还在睡觉的老妈,就一直在床上呆着,最多玩玩手机,并不打算起床干正事。谁能想到我就这么在床上浪费了 6h 的生命呢。

那么问题来了,今天早上 6 点,我总算是熬到头了,今天的一天要怎么办?是等到困了去补觉,继续着这么不规律的作息;还是继续熬着,但按时睡觉?

Case 2

高三的我每天都非常累,而这是有原因的:每天早上 5:55 起床,坐上 6:05 的出租车,在 6:15 之前到教室,然后读上约 15min 的书,会站着不由自主地进入浅睡眠状态。其后的第一、二节课基本也是如此,只有在吃饭或睡够了的时候才能稍微清醒一些。

由于停课缺课的原因,我比较缺乏训练,做题的速度不太行,每天的作业都写的很慢。每天的数学作业总是在上午布置,下午上课前提交,因此必须在午休的 1.5h 内写完。而我即使是整个午休撑住不睡也写不完这些作业,而且午休的后半部分还会因为实在太困效率进一步下降,写作业效果更差了。

由于午休并没很好地休息,下午的开头几节课仍然很困,听不进去什么东西,一直熬到晚饭后的晚自习。学校只安排了约 1h 晚自习时间用于自由写作业,剩下的约 2h 其实是在讲课或考试,因此每天的作业也基本写不完。

带着这么写写不完的作业回家,我每天都有很强的罪恶感,因此到家之后还是会稍微补一段时间的作业。晚自习通常 22:00 放,到家约 22:30,洗漱完毕就到了 23:00,再写一会作业,很容易就到第二天了。

除了写作业之外,我每天还会整理当天产生的错题,或找以前以往的错题重新做一遍。具体而言,整理错题需要用手机拍照,再打印下来,而重新做错题则是从以往的打印稿中裁一道题出来贴在错题本上,再在空白处重新解答。这其实是相当耗时间的,一方面排版和打印需要一定的时间,另一方面,由于订正时通常比第一遍做更认真详细,所以耗时会比常规做题长一些。

睡前还会玩一会手机,等真正睡着可能已经 1:30 了,而第二天又需要在 5:55 起床,每天的睡眠只有这么可怜的不到 5h,第二天又是困倦的一天呢。

这是一个恶性循环,长此以往,不仅学习效率没有提高,身体也会因为缺乏睡眠而变差,所以应该怎么破解这个循环?

Case 3

我是一个喜欢水群的人,而群总是在晚上最活跃。每天晚上,我都能水满整个活跃期,并把作业放在这之后完成——因为作业有第二天的 ddl,我同时也是一个拖延症患者。

由于晚睡,第二天显然会晚起,而安排在早 8 的所有课程就会因此错过,只能留到以后再补。补看这些课程通常都是在晚上进行的,而又因为水群,补看的学习率其实并不高,这会导致写作业不顺利,然后晚睡。

这也是一个恶性循环,怎么办?

Solution

BREAK IT

必须打破这个循环!哪怕是空出一天什么都不干,只用来调整作息到正常的状态,并且睡个好觉,让自己在第二天能够正常学习工作,都是可以接受的。因为既然已经虚度了那么多天的光阴,又怎么会差这一天呢?与其继续浑浑噩噩,不如牺牲一天时间,换取之后的高效学习。

CHANGE IT

新的一天里,按时起了个大早,然后呢?如果继续像之前一样颓废、水群,那么又会迅速回到上文所述的恶性循环中,所以必须改变!有计划地安排自己的白天时间,把学习或工作主要放在白天完成,而晚上就可以自由安排自习或者娱乐了。此外,必须保持下去合理的生活习惯,不然前面做的这些决心全部木大之后,反而会让自己陷入更深的罪恶感和自责当中。

BE ACTIVE

这件事的根源在于,我没能掌握自己的生活。生活应该是自己的生活,自己对自己的生活要进行经营、管理、负起责任。所以,需要主动一些,尝试去掌握自己的生活,尤其是生活失控的时候,更应该主动地去纠正偏离正轨的部分。当然,强行掰回来也许是不现实的,也可以留有慢慢修正的余地。

CONFIDENCE

不管当前的状况再怎么差,最怕的是失去了信心、失去了动力。缺乏信心的情况下,自己是不愿意做出努力来改变现状的,而放任失控的生活继续这么过分下去。我自己至少在这一点上做的还不错,我常对未来有乐观的预期,但是缺乏行动力,这也是今天早上写这篇随笔进行反思的一个原因。

Postscript

Case 1 其实就是今天早上发生了的真实事件,而它也是促使我反思自己昨天经历的事情,乃至回忆起自己之前关于恶性循环的观点的原因。写下这篇随笔,不仅是想分享我的观点,更是通过反思和自我批评来鞭策自己进行改变。

我已经如此度过了这个学期的前一半,并因为恶性循环导致了学习率低下、毫无进步、课外项目荒废的严重后果。如果再不进行改变,这半年又跟白过有什么区别呢?

我是 KSkun,请监督我学习/干活。

KSkun
2020/5/19

[CSP-S2 2019]划分 题解

[CSP-S2 2019]划分 题解

题目地址:洛谷:P5665 划分 – 洛谷 | 计算机科学教育新生态

题目描述

2048 年,第三十届 CSP 认证的考场上,作为选手的小明打开了第一题。这个题的样例有 $n$ 组数据,数据从 $1 \sim n$ 编号,$i$ 号数据的规模为 $a_i$。

小明对该题设计出了一个暴力程序,对于一组规模为 $u$ 的数据,该程序的运行时间为 $u^2$。然而这个程序运行完一组规模为 $u$ 的数据之后,它将在任何一组规模小于 $u$ 的数据上运行错误。样例中的 $a_i$ 不一定递增,但小明又想在不修改程序的情况下正确运行样例,于是小明决定使用一种非常原始的解决方案:将所有数据划分成若干个数据段,段内数据编号连续,接着将同一段内的数据合并成新数据,其规模等于段内原数据的规模之和,小明将让新数据的规模能够递增。

也就是说,小明需要找到一些分界点 $1 \le k_1 < k_2 < \cdots < k_p < n$,使得:

$$
\sum_{i=1}^{k_1} a_i\le \sum_{i=k_1+1}^{k_2} a_i \le \dots \le \sum_{i=k_p+1}^n a_i
$$

注意 $p$ 可以为 $0$ 且此时 $k_0 = 0$,也就是小明可以将所有数据合并在一起运行。

小明希望他的程序在正确运行样例情况下,运行时间也能尽量小,也就是最小化

$$
\left(\sum_{i=1}^{k_1} a_i \right)^2+\left(\sum_{i=k_1}^{k_2} a_i \right)^2+\cdots +\left(\sum_{i=k_p+1}^n a_i \right)^2
$$

小明觉得这个问题非常有趣,并向你请教:给定 $n$ 和 $a_i$,请你求出最优划分方案下,小明的程序的最小运行时间。

题意简述

给定一个长度为 $n$ 的数列 $\{a_i\}$,求对数列的一个划分 $T = \{ k_1, k_2, \dots, k_p \}$,使得该划分满足 $\sum\limits_{i=1}^{k_1} a_i\le \sum\limits_{i=k_1+1}^{k_2} a_i \le \dots \le \sum\limits_{i=k_p+1}^n a_i$,且最小化 $ \left(\sum\limits_{i=1}^{k_1} a_i \right)^2+\left(\sum\limits_{i=k_1}^{k_2} a_i \right)^2+\cdots +\left(\sum\limits_{i=k_p+1}^n a_i \right)^2 $。

输入输出格式

输入格式:

由于本题的数据范围较大,部分测试点的 $a_i$ 将在程序内生成

第一行两个整数 $n, \text{type}$。$n$ 的意义见题目描述,$\text{type}$ 表示输入方式。

  1. 若 $\text{type} = 0$,则该测试点的 $a_i$ 直接给出。输入文件接下来:第二行 $n$ 个以空格分隔的整数 $a_i$,表示每组数据的规模。
  2. 若 $\text{type} = 1$,则该测试点的 $a_i$ 将特殊生成,生成方式见后文。输入文件接下来:第二行六个以空格分隔的整数 $x, y, z, b_1, b_2, m$。接下来 $m$ 行中,第 $i$($1 \le i \le m$)行包含三个以空格分隔的正整数 $p_i, l_i, r_i$。

对于 $\text{type} = 1$ 的 $23 \sim 25$ 号测试点,$a_i$ 的生成方式如下:

  • 给定整数 $x, y, z, b_1, b_2, m$,以及 $m$ 个三元组 $(p_i, l_i, r_i)$。
  • 保证 $n \ge 2$。若 $n > 2$,则 $\forall 3\le i\le n$,$b_i = (x \times b_{i−1} + y \times b_{i−2} + z) \bmod 2^{30}$。
  • 保证 $1 \le p_i \le n$,$p_m = n$。令 $p_0 = 0$,则 $p_i$ 还满足 $\forall 0 \le i < m$ 有 $p_i < p_{i+1}$。
  • 对于所有 $1 \le j \le m$,若下标值 $i$($1 \le i \le n$)满足 $p_{j−1} < i \le p_j$,则有

$$
a_i=\left( b_i \bmod (r_j-l_j+1) \right) +l_j
$$

上述数据生成方式仅是为了减少输入量大小,标准算法不依赖于该生成方式

输出格式:

输出一行一个整数,表示答案。

输入输出样例

样例 #1

输入样例#1:

5 0
5 1 7 9 9

输出样例#1:

247

样例 #2

输入样例#2:

10 0
5 6 7 7 4 6 2 13 19 9

输出样例#2:

1256

样例 #3

输入样例#3:

10000000 1
123 456 789 12345 6789 3
2000000 123456789 987654321
7000000 234567891 876543219
10000000 456789123 567891234

输出样例#3:

4972194419293431240859891640

数据范围

测试点编号$n\le$$a_i\le$$\text{type}=$
$1\sim 3$$10$$10$$0$
$4\sim 6$$50$$10^3$$0$
$7\sim 9$$400$$10^4$$0$
$10\sim 16$$5\times 10^3$$10^5$$0$
$17\sim 22$$5\times 10^5$$10^6$$0$
$23\sim 25$$4\times 10^7$$10^9$$1$

对于 $\text{type} = 0$ 的测试点,保证答案不超过 $4\times 10^{18}$。

所有测试点满足:$\text{type} \in {0, 1} , 2 \le n \le 4 \times 10^7 , 1 \le a_i \le 10^9 , 1 \le m \le 10^5 ,1 \le l_i \le r_i \le 10^9 , 0 \le x, y, z, b_1, b_2 < 2^{30}$。

题解

参考资料:

这个题的部分思路并不难,但最优解法确实具有一定的难度和复杂程度,以至于从开始写到整理思路写这篇题解一共用了三天时间。

最初的 DP

首先可以设计出一个比较简单的 DP,状态 $f(i, j)$ 表示对前 $i$ 个数字分段,上一段的结尾为 $j$ 的时候段平方和的最小值。转移时,枚举上一段的结尾 $j$,再枚举上上一段的结尾 $k$,先判断是否满足转移条件 $\sum\limits_{t=k+1}^j a_t \leq \sum\limits_{t’=j+1}^i a_{t’}$,然后进行最小化转移:

$$ f(i, j) = \min_{ \sum\limits_{t=k+1}^j a_t \leq \sum\limits_{t’=j+1}^i a_{t’} } \left\{ f(j, k) + \left( \sum\limits_{t=j+1}^i a_i \right)^2 \right\} $$

显然这个 DP 的复杂度为 $O(n^3)$。

贪心策略与 DP 优化

看这个式子:

$$ a^2 + b^2 < (a+b)^2 \ \ \ (a, b > 0) $$

在转移时,最后一段最长肯定越可能满足转移条件。当段较长的时候,可能将段从中间拆成多段也可以满足转移条件。假设切为两段后满足转移条件且前一段之和为 $a$、后一段之和为 $b$,上面的式子告诉我们,这里切成两段后的段平方和更小。因此,段肯定是越短越好的,或者也可以说,最后一段越短越好。事实上,段越短有利于之后分的段也较短。

因此,这里有贪心策略:段分得越短越好。

考虑将贪心应用进 DP 的过程中,既然最后一段越短越好,最优解中的最后一段一定是最短的,因此上一段的结尾不再有多种可能性,而是直接选择使最后一段最短的那一个。将前 $i$ 个数字分段的最优解中,上一段的结尾记为 $lst(i)$,DP 状态也可改为 $f(i)$ 代表前 $i$ 个数字分段的最优解的段平方和。转移方程如下:

$$ f(i) = \min_{ \sum\limits_{t=lst(j)+1}^j a_t \leq \sum\limits_{t’=j+1}^i a_{t’} } \left\{ f(j) + \left( \sum\limits_{t=j+1}^i a_i \right)^2 \right\} $$

复杂度降为 $O(n^2)$,但依然不够。

单调队列

记 $a_i$ 的前缀和为 $s(i)$,将转移条件写成前缀和的形式:

$$ s(j)-s[lst(j)-1] \leq s(i)-s(j) $$

移项可得

$$ s(i) \geq 2s(j)-s[lst(j)-1] $$

上式的左边只与 $i$ 有关,右边只与 $j$ 有关,我们需要找到满足上述条件的最大的 $j$。

此外,还可以发现,当一个 $j$ 满足上述条件时,根据前缀和的单增性可以得到 $s(i+1) \geq s(i) \geq 2s(j)-s[lst(j)-1]$,即 $j$ 也可以作为 $i+1$ 的决策。因此可行的决策点是一个以 $1$ 为左端点的区间,当 $i$ 增大时,决策点的区间右端点也是单增的(性质 1)。显然,$2s(j)-s[lst(j)-1]$ 的值越小,$j$ 越可能成为合法的决策点,也就是说,记 $A(j) = 2s(j)-s[lst(j)-1]$,当 $j_1 > j_2$ 且 $A(j_1) \leq A(j_2)$ 时,$j_1$ 一定优于 $j_2$(性质 2)。

上述性质决定了我们可以应用单调队列优化 DP 转移的复杂度:维护一个 $A(j)$ 数值单调递增且 $j$ 也单增的单调队列,每次转移时找到队列中最大的满足上述条件的决策点 $j_m$(性质 1),转移后仅保留 $j_m$ 而弹出小于 $j_m$ 的满足条件的决策点(性质 2)。转移完成后,计算 $A(i)$ 再将 $i$ 放入队列中,作为以后的状态的可能决策点。

利用单调队列优化 DP 后,转移的复杂度变为 $O(1)$,则总复杂度为 $O(n)$,可以通过极限数据。

卡常

由于最开始的写法使用了大量 STL 和常数略大的写法,在各种 OJ 上都跑不过极限数据,于是开始了卡常。

  • 首先自然是用了 fread 版快读,这算是我的一个习惯。
  • 在高精计算过程中利用特殊条件判断减少了总运算次数,尤其是减少了取模的次数。
  • 原本是在输入之后单独算一遍前缀和,改成了边输入边算前缀和。
  • 由于有点卡空间,复用了前缀和数组 s 的空间作为生成数据用的临时空间。
  • 单调队列没封装,直接把实现展开到 DP 的过程中了。
  • 手动开了个 O3。

最后终于在洛谷上 AC 了。不过没卡的这么极端的时候也能在 LOJ、UOJ 之类的地方跑过,可能洛谷跑的会略有点慢。

严谨性说明和性质证明

本题解的说明并不严谨,在说明单调队列优化转移时并未给出相关性质的证明,表述也不尽清晰。本文受本人水平、时间、精力等所限,暂时无法呈现出更好的效果,欢迎提出建议或改写本文部分内容。

关于有关性质的证明,可以在毛爷爷的博客:CSP2019划分的简要题解 – 博客 – matthew99的博客阅读。

代码

#pragma GCC optimize(3) // 手动开 O3
// Code by KSkun, 2019/12
#include <cstdio>
#include <cctype>
#include <cstring>

#include <algorithm>
#include <utility>

typedef unsigned long long LL;
typedef std::pair<int, int> PII;

inline char fgc() {
	static char buf[100000], * p1 = buf, * p2 = buf;
	return p1 == p2 && (p2 = (p1 = buf) + fread(buf, 1, 100000, stdin), p1 == p2)
		? EOF : *p1++;
}

inline LL readint() {
	register LL res = 0, neg = 1; register char c = fgc();
	for (; !isdigit(c); c = fgc()) if (c == '-') neg = -1;
	for (; isdigit(c); c = fgc()) res = res * 10 + c - '0';
	return res * neg;
}

inline char readsingle() {
	char c;
	while (!isgraph(c = fgc())) {}
	return c;
}

const int MAXN = 40000005, MAXM = 100005;;
const LL MASK = (1ull << 30) - 1; // % 2^30 和 & MARK 等价

int n, type, lst[MAXN];
LL s[MAXN];

void gen_data() {
	LL x, y, z, m;
	x = readint(); y = readint(); z = readint();
	s[1] = readint(); s[2] = readint();
	m = readint();
	int* p = new int[MAXM], * l = new int[MAXM], * r = new int[MAXM];
	p[0] = l[0] = r[0] = 0;
	for (int i = 1; i <= m; i++) {
		p[i] = readint();
		l[i] = readint();
		r[i] = readint();
	}
	for (int i = 3; i <= n; i++) {
		s[i] = (x * s[i - 1] + y * s[i - 2] + z) & MASK; // 复用 s 数组省空间
	}
	for (int i = 1; i <= m; i++) {
		for (int j = p[i - 1] + 1; j <= p[i]; j++) {
			s[j] = (s[j] % (r[i] - l[i] + 1)) + l[i] + s[j - 1]; // 复用 s 数组省空间
		}
	}
	delete[] p; delete[] l; delete[] r; // 内存回收
}

const int BN_MAX = 1e9;
struct BigNum {
	LL dat[5];
	int len;
	BigNum() {
		dat[0] = dat[1] = dat[2] = dat[3] = dat[4] = 0; // 手动初始化没用 memset 减小常数
		len = 0;
	}
	BigNum(LL x) {
		dat[0] = dat[1] = dat[2] = dat[3] = dat[4] = 0;
		len = 0;
		while (x > 0) {
			dat[len++] = x % BN_MAX;
			x /= BN_MAX;
		}
	}

	BigNum operator+(const BigNum& rhs) const {
		BigNum res;
		if (len == 0) return rhs; // 减少运算次数
		if (rhs.len == 0) return *this;

		res.len = std::max(len, rhs.len);
		for (register int i = 0; i < res.len; i++) {
			if (dat[i] == 0 && rhs.dat[i] == 0) continue; // 减少运算次数
			res.dat[i] += dat[i] + rhs.dat[i];
			if (res.dat[i] > BN_MAX) { // 减少运算次数
				res.dat[i + 1] += res.dat[i] / BN_MAX;
				res.dat[i] %= BN_MAX;
			}
		}
		if (res.dat[res.len] != 0) res.len++;
		return res;
	}
	BigNum operator*(const BigNum& rhs) const {
		BigNum res;
		if (len == 0 || rhs.len == 0) return res;

		res.len = len + rhs.len - 1;
		for (register int i = 0; i < len; i++) {
			if (dat[i] == 0) continue; // 减少运算次数
			for (register int j = 0; j < rhs.len; j++) {
				if (rhs.dat[j] == 0) continue; // 减少运算次数
				res.dat[i + j] += dat[i] * rhs.dat[j];
			}
		}
		for (register int i = 0; i < res.len; i++) {
			if (res.dat[i] > BN_MAX) { // 减少运算次数
				res.dat[i + 1] += res.dat[i] / BN_MAX;
				res.dat[i] %= BN_MAX;
			}
		}
		if (res.dat[res.len] != 0) res.len++;
		return res;
	}
};

int d_dat[MAXN], d_l, d_r;

inline LL A(int idx) {
	return 2 * s[idx] - s[lst[idx]];
}

int main() {
	n = readint(); type = readint();

	if (type == 0) {
		for (register int i = 1; i <= n; i++) {
			s[i] = s[i - 1] + readint(); // 输入时就把前缀和做了,减小单独计算的常数
		}
	} else {
		gen_data();
	}

	d_l = d_r = 0; // 单调队列手写而且没封装,常数优化
	for (register int i = 1; i <= n; i++) {
		int lst_ele = 0;
		while (d_l != d_r && s[i] >= A(d_dat[d_l])) {
			lst_ele = d_dat[d_l]; d_l++;
		}
		lst[i] = lst_ele; d_dat[--d_l] = lst_ele;
		LL Ai = A(i); // 没空间存 A 数组,所以用函数,这里是避免重复运算减小常数
		while (d_l != d_r && Ai <= A(d_dat[d_r - 1])) d_r--;
		d_dat[d_r++] = i;
	}

	BigNum ans;
	for (register int i = n; i != 0; i = lst[i]) {
		BigNum t = BigNum(s[i] - s[lst[i]]);
		ans = ans + t * t;
	}
	for (register int i = ans.len - 1; i >= 0; i--) {
		if (i == ans.len - 1) {
			printf("%llu", ans.dat[i]);
		} else {
			printf("%09llu", ans.dat[i]);
		}
	}

	return 0;
}