当前位置: 首页 > news >正文

模拟题3——CSP202409C. 补丁应用

一、题目概述

这道题要求我们实现一个简化版的patch程序。

程序首先读入一个原文件,然后读入由若干补丁块组成的补丁。每个补丁块描述原文件中的一段内容,以及这段内容被修改后的结果。我们需要检查补丁是否合法、确定每个补丁块在原文件中的实际位置,并输出应用全部补丁后的文件。

一个补丁块的形式如下:

@@ -NN,MM +nn,mm @@ -旧内容 +新内容 不变内容

其中:

  • NN:这次修改预计从原文件第几行开始;

  • MM:原文件片段包含多少行;

  • nn:修改后片段预计从第几行开始,本题不使用;

  • mm:修改后的片段包含多少行。

补丁内容中的每一行还有一个标记字符:

标记含义属于原文件片段属于新文件片段
-删除这一行
+添加这一行
空格这一行没有变化

例如:

@@ -1,4 +1,5 @@ -a +b 1 +c 2 3

从中可以提取出原文件片段:

a 1 2 3

以及新文件片段:

b 1 c 2 3

因此,一个补丁块可以抽象成:

struct Block { long long NN; int MM; int mm; vector<string> oldPart; vector<string> newPart; };

oldPart用于在原文件中查找实际位置,newPart用于最后生成修改后的文件。


二、先删除注释,再划分补丁块

2.1 题意分析

原文件的n行读取完以后,剩余输入才是补丁。

补丁中所有以#开头的行都是注释,必须先删除。例如:

# this is a comment

会被直接忽略。

但是下面这一行不是注释:

# this is file content

因为它的第一个字符是空格。在补丁块中,这表示原文件和新文件中都存在文本# this is file content

删除注释以后,每一个以@开头的行都表示一个新块的开始。第一个@开头的行之前出现的普通文本全部忽略。

如果整个补丁中没有找到以@开头的行,补丁损坏。

2.2 代码设计

先读取并删除注释:

vector<string> patchLines; while (getline(cin, line)) { if (!line.empty() && line[0] == '#') { continue; } patchLines.push_back(line); }

然后划分补丁块:

vector<vector<string>> rawBlocks; for (const string& s : patchLines) { if (!s.empty() && s[0] == '@') { rawBlocks.push_back({}); } if (!rawBlocks.empty()) { rawBlocks.back().push_back(s); } }

这里有两个细节:

  1. 遇到@开头的行时,先创建一个新块;

  2. 只有已经找到第一个块以后,才把行加入块中,因此块之前的普通文本自然被忽略。


三、严格解析块头

3.1 题意分析

每个块的第一行必须严格符合:

@@ -NN,MM +nn,mm @@

四个数字都必须是正整数:

  • 第一位是19

  • 后面可以有若干位09

  • 不允许出现0

  • 不允许出现01这样的前导零;

  • 空格、逗号、加号、减号和@@的位置必须正确。

如果一个以@开头的行格式不正确,不能把它忽略,而应该判定补丁损坏。

3.2 代码设计

使用正则表达式进行完整匹配:

regex headerPattern( R"(^@@ -([1-9][0-9]*),([1-9][0-9]*) \+([1-9][0-9]*),([1-9][0-9]*) @@$)" );

四个捕获组依次对应:

result[1] -> NN result[2] -> MM result[3] -> nn result[4] -> mm

题目明确要求忽略nn,所以只需要通过正则表达式检查它的格式,不需要参与后续计算。

题目没有限制数字字符串的长度,直接使用stoi可能溢出。对于MMmm,可以不进行整数转换,而是将它们和实际行数的十进制字符串比较:

bool equalsCount(const string& s, size_t count) { return s == to_string(count); }

NN后面需要参与位置计算,因此使用一个带截断的转换函数:

long long parsePosition(const string& s) { long long value = 0; for (char c : s) { int digit = c - '0'; if (value > (INF - digit) / 10) { return INF; } value = value * 10 + digit; } return value; }

如果NN大得无法存入long long,就将它截断为一个极大值。原文件最多只有 2000 行,这样的位置不可能匹配成功,之后自然会判定补丁损坏。


四、从补丁内容中提取两个片段

4.1 题意分析

块头以后的每一行只能以以下三种字符之一开头:

- + 空格

如果出现其他开头,甚至出现空行,补丁都损坏。

每一行去掉第一个标记字符以后:

  • -行加入oldPart

  • +行加入newPart

  • 空格行同时加入oldPartnewPart

提取结束后:

  • oldPart的行数必须等于MM

  • newPart的行数必须等于mm

4.2 代码设计

for (int i = 1; i < (int)rawBlock.size(); ++i) { const string& current = rawBlock[i]; if (current.empty()) { damaged(); return 0; } char type = current[0]; if (type != '-' && type != '+' && type != ' ') { damaged(); return 0; } string content = current.substr(1); if (type == '-' || type == ' ') { block.oldPart.push_back(content); } if (type == '+' || type == ' ') { block.newPart.push_back(content); } }

注意,只有一个标记字符的行也是合法的。例如:

-

表示删除一个空文本行。此时current并不为空,current.substr(1)得到空字符串。

提取完成后检查行数:

if (!equalsCount(MMs, block.oldPart.size()) || !equalsCount(mms, block.newPart.size())) { damaged(); return 0; }

五、先检查所有块,再应用补丁

5.1 题意分析

题目规定,在应用任何补丁块之前,必须先完成所有块的格式检查。

因此,不能解析出一个块后立即应用它。否则,前面的块已经修改了文件,后面才发现另一个块格式错误,整个处理过程就不符合题目给出的顺序。

虽然发现补丁损坏时最终只输出一句:

Patch is damaged.

但在程序设计上,将“解析验证”和“应用补丁”分成两个阶段,会让逻辑更加清楚,也不容易混淆原文件和最终文件。

在格式检查阶段,还需要验证相邻块的原始行号关系:

当前块 NN >= 前一个块 NN + 前一个块 MM

5.2 代码设计

所有解析完成的块先保存到:

vector<Block> blocks;

加入当前块之前检查:

if (!blocks.empty()) { const Block& previous = blocks.back(); long long previousEnd = safeAdd(previous.NN, previous.MM); if (block.NN < previousEnd) { damaged(); return 0; } }

例如,前一个块从第 3 行开始,包含 4 行,那么它涉及第 3、4、5、6 行,下一个块至少要从第 7 行开始。


六、在原文件中确定每个块的实际位置

6.1 题意分析

源文件可能在补丁生成以后经历过其他修改,因此块头给出的NN不一定是当前真正的位置。

程序需要寻找一个整数δ,满足:

|δ| < MM

并且从原文件第NN + δ行开始的MM行,必须和oldPart完全相同。

如果当前块不是第一个块,还要满足:

NN + δ >= 前一个块的实际 NN + 前一个块的 MM

这样可以保证不同块对应的原文件区域不重叠。

如果有多个合法的δ

  1. 选择绝对值最小的;

  2. 绝对值相同时选择数值更小的。

因此,直接按以下顺序尝试即可:

0, -1, 1, -2, 2, -3, 3, ...

第一次匹配成功的偏移就是答案。

6.2 代码设计

检查候选偏移时,先计算实际起始位置:

long long actualStart = safeShift(blocks[i].NN, delta);

然后依次检查:

  1. 行号不能小于 1;

  2. 匹配片段不能超过原文件结尾;

  3. 不能和前一个块的原文件区域重叠;

  4. 原文件对应区域必须和oldPart完全一致。

auto tryDelta = [&](long long delta) { long long actualStart = safeShift(blocks[i].NN, delta); if (actualStart < 1) { return false; } if (actualStart - 1 + blocks[i].MM > (long long)original.size()) { return false; } if (i > 0) { long long previousEnd = safeAdd( blocks[i - 1].NN, blocks[i - 1].MM ); if (actualStart < previousEnd) { return false; } } return matches( original, actualStart, blocks[i].oldPart ); };

逐行匹配函数如下:

bool matches(const vector<string>& original, long long start, const vector<string>& pattern) { long long begin = start - 1; for (int i = 0; i < (int)pattern.size(); ++i) { if (original[begin + i] != pattern[i]) { return false; } } return true; }

因为原文件只有n <= 2000行,所以不需要使用 KMP,可以直接枚举偏移并逐行比较。


七、最关键的坑:匹配阶段不能修改 original

7.1 容易写错的做法

一种很自然但错误的想法是:

  1. 找到第一个块的位置;

  2. 立即删除oldPart

  3. 插入newPart

  4. 在已经修改的文件上继续查找第二个块。

也就是类似:

file.erase(...); file.insert(...);

这样单块补丁可能正确,但多块补丁会出错。

7.2 正确理解

所有块的oldPart都必须在最初输入的原文件中定位。

匹配阶段只负责确定:

每个块实际对应原文件中的哪一段

整个匹配过程中:

vector<string> original;

必须保持不变。

所有块的位置确定以后,再根据这些互不重叠的原文件区域一次性生成最终文件。

7.3 用样例理解两次偏移

样例原文件为:

1: bbb 2: a 3: 1 4: 2 5: 3 6: 4 7: 5

第一块原本预计从第 1 行开始,但其oldPart实际位于第 2~5 行:

δ = 2 - 1 = 1

于是当前块和后续块的NN都加 1:

第一块:1 -> 2 第二块:6 -> 7

第二块现在预计从第 7 行开始,但它的oldPart

4 5

位于最初原文件的第 6~7 行,因此:

δ = 6 - 7 = -1

第二块的实际位置最终变为第 6 行。

所以两个块最终对应:

第一块:原文件第 2~5 行 第二块:原文件第 6~7 行

如果第一块结束后立即修改文件,第二块匹配时使用的行号体系就已经变化,这正是多块补丁容易 WA 的原因。


八、偏移为什么要传递给后续块

8.1 题意分析

当前块找到偏移δ后,题目要求:

当前块及其后的所有块的 NN 都加上 δ

例如,当前块和后续块的NN是:

10, 20, 30

如果当前块找到:

δ = 2

它们就变成:

12, 22, 32

这表示既然当前区域整体向后偏移了两行,那么后续区域的预计位置也要一起向后移动。

8.2 代码设计

for (int j = i; j < (int)blocks.size(); ++j) { blocks[j].NN = safeShift(blocks[j].NN, bestDelta); }

处理完第i个块以后:

  • blocks[i].NN是当前块在原文件中的实际位置;

  • 后续块的NN已经包含前面块造成的累计偏移。

这也是后续判断区域是否重叠时,可以直接使用前一个块NN + MM的原因。


九、所有位置确定后统一生成答案

9.1 题意分析

经过前面的匹配,每个块都已经知道自己对应原文件中的哪一段。

假设两个块对应:

第一块:[2, 5] 第二块:[8, 9]

最终输出顺序为:

  1. 原文件第 1 行;

  2. 第一块的newPart

  3. 原文件第 6~7 行;

  4. 第二块的newPart

  5. 原文件第 10 行至结尾。

9.2 代码设计

使用currentLine表示原文件中下一行尚未处理的行号:

int currentLine = 1;

对于每个块:

for (const Block& block : blocks) { int actualStart = (int)block.NN; while (currentLine < actualStart) { cout << original[currentLine - 1] << '\n'; ++currentLine; } for (const string& s : block.newPart) { cout << s << '\n'; } currentLine = actualStart + block.MM; }

这三部分分别表示:

  1. 输出当前块之前没有变化的原文件内容;

  2. 输出当前块修改后的新内容;

  3. 跳过原文件中被替换的MM行。

最后输出最后一个块之后的原文件内容:

while (currentLine <= n) { cout << original[currentLine - 1] << '\n'; ++currentLine; }

十、完整程序执行流程

整份程序可以归纳为以下流程:

  1. 读取n和原文件的n行,保存到original

  2. 读取剩余的补丁文本;

  3. 删除所有以#开头的注释行;

  4. 从第一个@开头的行开始划分补丁块;

  5. 如果没有找到任何块,输出补丁损坏;

  6. 对每个块严格解析@@ -NN,MM +nn,mm @@

  7. 根据-+、空格提取oldPartnewPart

  8. 检查两个片段的行数是否分别等于MMmm

  9. 检查各块原始NN的顺序是否合法;

  10. 所有格式检查通过后,开始依次定位每个块;

  11. 0,-1,1,-2,2...枚举合法的δ

  12. 始终在未修改的original中匹配oldPart

  13. 检查当前块不能和前一个块的原文件区域重叠;

  14. 找到最优δ后,更新当前块及后续块的NN

  15. 如果某个块没有合法匹配位置,输出补丁损坏;

  16. 所有块定位成功后,按照原文件片段和各块的newPart统一输出最终结果。

整个算法中需要始终维持三个不变量:

  1. original在匹配阶段永远不修改;

  2. 已经处理的块,其NN是实际匹配位置;

  3. 尚未处理的块,其NN已包含前面所有块的累计偏移。


十一、复杂度分析

对于一个块,合法偏移满足:

|δ| < MM

因此最多尝试2MM-1个偏移,每次最多比较MM行,一个块的最坏复杂度为:

O(MM²)

由于所有匹配都发生在最初的原文件中,所以:

MM <= n <= 2000

补丁块最多 25 个,总时间复杂度可以写为:

O(k × n²)

其中k <= 25。在本题范围内,直接逐行比较足够通过,不需要 KMP。

空间主要用于保存原文件和补丁内容,空间复杂度与输入总长度呈线性关系。


十二、完整 C++17 代码

#include <bits/stdc++.h> using namespace std; const long long INF = 4'000'000'000'000'000'000LL; struct Block { long long NN; int MM; int mm; vector<string> oldPart; vector<string> newPart; }; long long parsePosition(const string& s) { long long value = 0; for (char c : s) { int digit = c - '0'; if (value > (INF - digit) / 10) { return INF; } value = value * 10 + digit; } return value; } bool equalsCount(const string& s, size_t count) { return s == to_string(count); } long long safeAdd(long long a, long long b) { if (a >= INF - b) { return INF; } return a + b; } long long safeShift(long long position, long long delta) { if (delta > 0 && position > INF - delta) { return INF; } if (delta < 0 && position < -delta) { return 0; } return position + delta; } bool matches(const vector<string>& original, long long start, const vector<string>& pattern) { if (start < 1) { return false; } long long begin = start - 1; if (begin + (long long)pattern.size() > (long long)original.size()) { return false; } for (int i = 0; i < (int)pattern.size(); ++i) { if (original[begin + i] != pattern[i]) { return false; } } return true; } void damaged() { cout << "Patch is damaged.\n"; } int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n; cin >> n; string line; getline(cin, line); vector<string> original(n); for (string& s : original) { getline(cin, s); } vector<string> patchLines; while (getline(cin, line)) { if (!line.empty() && line[0] == '#') { continue; } patchLines.push_back(line); } vector<vector<string>> rawBlocks; for (const string& s : patchLines) { if (!s.empty() && s[0] == '@') { rawBlocks.push_back({}); } if (!rawBlocks.empty()) { rawBlocks.back().push_back(s); } } if (rawBlocks.empty()) { damaged(); return 0; } regex headerPattern( R"(^@@ -([1-9][0-9]*),([1-9][0-9]*) \+([1-9][0-9]*),([1-9][0-9]*) @@$)" ); vector<Block> blocks; for (const auto& rawBlock : rawBlocks) { if (rawBlock.empty()) { damaged(); return 0; } smatch result; if (!regex_match(rawBlock[0], result, headerPattern)) { damaged(); return 0; } string NNs = result[1].str(); string MMs = result[2].str(); string mms = result[4].str(); Block block; block.NN = parsePosition(NNs); for (int i = 1; i < (int)rawBlock.size(); ++i) { const string& current = rawBlock[i]; if (current.empty()) { damaged(); return 0; } char type = current[0]; if (type != '-' && type != '+' && type != ' ') { damaged(); return 0; } string content = current.substr(1); if (type == '-' || type == ' ') { block.oldPart.push_back(content); } if (type == '+' || type == ' ') { block.newPart.push_back(content); } } if (!equalsCount(MMs, block.oldPart.size()) || !equalsCount(mms, block.newPart.size())) { damaged(); return 0; } block.MM = (int)block.oldPart.size(); block.mm = (int)block.newPart.size(); if (!blocks.empty()) { const Block& previous = blocks.back(); long long previousEnd = safeAdd(previous.NN, previous.MM); if (block.NN < previousEnd) { damaged(); return 0; } } blocks.push_back(move(block)); } for (int i = 0; i < (int)blocks.size(); ++i) { bool found = false; long long bestDelta = 0; auto tryDelta = [&](long long delta) { long long actualStart = safeShift(blocks[i].NN, delta); if (actualStart < 1) { return false; } if (actualStart - 1 + blocks[i].MM > (long long)original.size()) { return false; } if (i > 0) { long long previousEnd = safeAdd( blocks[i - 1].NN, blocks[i - 1].MM ); if (actualStart < previousEnd) { return false; } } return matches( original, actualStart, blocks[i].oldPart ); }; if (tryDelta(0)) { found = true; bestDelta = 0; } else { for (int distance = 1; distance < blocks[i].MM && !found; ++distance) { if (tryDelta(-distance)) { found = true; bestDelta = -distance; } else if (tryDelta(distance)) { found = true; bestDelta = distance; } } } if (!found) { damaged(); return 0; } for (int j = i; j < (int)blocks.size(); ++j) { blocks[j].NN = safeShift(blocks[j].NN, bestDelta); } } int currentLine = 1; for (const Block& block : blocks) { int actualStart = (int)block.NN; while (currentLine < actualStart) { cout << original[currentLine - 1] << '\n'; ++currentLine; } for (const string& s : block.newPart) { cout << s << '\n'; } currentLine = actualStart + block.MM; } while (currentLine <= n) { cout << original[currentLine - 1] << '\n'; ++currentLine; } return 0; }

十三、总结

这道题表面上是一道字符串模拟题,真正的难点是维护补丁块的位置含义。

实现时最重要的原则是:

所有补丁块都在最初输入的原文件中定位;匹配阶段不修改原文件;所有位置确定后再统一生成最终结果。

在此基础上,再将程序拆分为:

删除注释 → 划分补丁块 → 检查块头 → 提取新旧片段 → 检查块顺序 → 枚举偏移并匹配原文件 → 传递偏移 → 统一输出

就能比较清晰地完成整个patch模拟过程。

转载注明出处

http://www.jsqmd.com/news/1284355/

相关文章:

  • AI UI设计已进入淘汰赛:这4类设计师将在2025年前彻底出局
  • Python小红书数据采集终极指南:5个技巧掌握高效爬虫技术
  • 基于libevent与libevhtp构建高性能HTTP服务器:从原理到实战
  • 华为MetaERP oracle ebs 成本要素的设计哲学 实现逻辑以及实现流程
  • 2026年AI智能体实战开发:从环境搭建到多场景部署全流程
  • AI Agent的2026路线图:从Tool Calling到Multi-Agent协作的工程路径
  • 2026下半年AI效率工具趋势:从单点工具到Agent化工作流的范式转移
  • 苏州逾期欠款追偿律师推荐,朱庆帅律师值得信赖 - 品牌排行榜
  • 华为非AI方向笔试真题 7月24号【最优河堤加固方案】
  • 2026论文降重工具与AIGC检测规避全攻略
  • BDD与Cucumber实践指南:用Gherkin语法编写可执行需求,驱动团队高效协作
  • USB协议深度解析:从核心架构、通信机制到实战开发与调试
  • 中小企业如何借力虚实共建引擎,低成本迈入产业元宇宙时代
  • 炉石传说佣兵战记Python自动化脚本:5分钟掌握智能游戏助手使用指南
  • 【AI黑话日日新】什么是分布式训练?从数据并行到 ZeRO 优化的全景解析
  • 上班族兼职做抖音小店,一件代发轻量化运营方案 - 抖掌柜
  • 物联网设备安全芯片SE050与PIC18F85K90组合方案解析
  • AI创业的下一个风口:垂直行业Agent的机会图谱与切入策略
  • 从EGG到钢铁版:打造高可靠边缘计算节点的软硬件实践
  • STM32与A5000实现物联网安全通信方案
  • 覆盖率详解:概念、类型、工具与实践指南
  • Shell中的变量
  • STM32与A5000加密芯片的物联网安全方案设计
  • 3D打印尺寸偏差分析与补偿优化:从原理到实践实现精密装配
  • 如何让单机游戏变成本地多人派对?终极分屏工具Nucleus Co-Op完整指南
  • Allegro 保存文件时提示被锁定了,但实际上是没有人为的设置密码,要怎么解锁呢?
  • 2026年英国留学重庆靠谱四名词评测:五家优选深度解析 - 科技焦点
  • 掌控板2.0与MQTT协议打造智能语音台灯:从物联网通信到微信小程序开发
  • C语言字符数组与字符串
  • Grasscutter Tools完整攻略:原神私服管理的一站式解决方案