先来看题目要求:给定一个字符串s和两个字符c1、c2,要求统计所有满足以下条件的子串数量——子串以c1开头、c2结尾,且长度至少为k。举个例子,字符串"abacaba"中,当c1='a'、c2='a'、k=3时,符合条件的子串有"aba"、"abaca"、"bacaba"和整个字符串本身。
最直观的解法就是暴力枚举。我刚开始做这道题时也是这么想的:遍历字符串中每个c1出现的位置,然后从这个位置向后扫描,遇到c2就检查子串长度是否达标。这种思路直接对应了双重循环的实现:
cpp复制for(int i = 0; i < s.size(); i++) {
if(s[i] != c1) continue;
for(int j = i + 1; j < s.size(); j++) {
if(s[j] == c2 && j-i+1 >= k) ans++;
}
}
这个解法虽然简单直接,但效率确实是个问题。假设字符串长度为n,最坏情况下(比如全a字符串找a...a子串)时间复杂度是O(n²)。在蓝桥杯比赛中,当n达到1e5量级时,这样的算法肯定会超时。我曾在模拟测试中吃过这个亏——明明逻辑正确的代码,却因为数据规模太大而无法通过。
让我们更细致地分析暴力解法的问题所在。假设字符串中c1出现m次,c2出现n次,那么最坏情况下需要做m×n次比较。当字符串中这两个字符出现频率很高时(比如各占一半),时间复杂度就接近O(n²)了。
在实际测试中,我用一个包含1e5个'a'的字符串测试暴力解法,输入参数为c1='a'、c2='a'、k=1。理论上应该有大约5e9个子串符合条件(这是个组合数学问题,约等于n²/2),但暴力解法在我的机器上跑了近10秒才出结果——这在算法竞赛中是完全不可接受的。
更关键的是,这种解法做了大量重复工作。每次遇到一个c2时,都要向前遍历检查所有c1的位置。实际上,对于特定的c2位置j,我们只需要知道前面有多少个c1的位置i满足j-i+1≥k这个条件即可。这个观察是优化的重要突破口。
既然暴力解法的问题在于重复检查,那么有没有办法预处理c1的位置,然后快速查询呢?这里就引出了二分查找的优化思路。具体来说:
预处理阶段:遍历字符串,记录所有c1出现的位置,存储在一个数组中。这个数组自然是有序的,因为我们是按顺序遍历字符串的。
查询阶段:对于每个c2出现的位置j,我们需要找到前面有多少个c1的位置i满足j-i+1≥k,即i≤j-k+1。这相当于在预处理好的c1位置数组中,统计有多少个元素≤(j-k+1)。
这个查询正好可以用二分查找高效完成!因为c1位置数组是有序的,我们可以用upper_bound或lower_bound函数快速定位分界点。在实际编码中,我更喜欢手写二分查找,因为边界条件更可控。
cpp复制vector<int> pc1; // 存储所有c1的位置
for(int i = 0; i < s.size(); i++)
if(s[i] == c1) pc1.push_back(i);
for(int j = 0; j < s.size(); j++) {
if(s[j] != c2) continue;
int target = j - k + 1;
if(target < 0) continue;
// 二分查找pc1中<=target的元素个数
int l = 0, r = pc1.size() - 1;
while(l < r) {
int mid = (l + r + 1) >> 1;
if(pc1[mid] <= target) l = mid;
else r = mid - 1;
}
if(pc1[l] <= target) ans += (l + 1);
}
二分查找虽然思路简单,但实现时有很多细节需要注意,这也是算法竞赛中常见的"坑点"。在我的多次参赛经历中,二分查找的边界问题导致的错误占了调试时间的很大比例。
首先要注意的是二分查找的初始条件。当pc1数组为空时(即字符串中没有c1),应该直接跳过。这在代码中通过检查pc1.size()来实现。
其次是二分查找的终止条件。我们使用的是l < r这个条件,这意味着循环结束时l和r会相等。这里我选择的是查找最后一个≤target的元素,因此mid的计算要加1防止死循环(即mid = (l + r + 1) >> 1)。
还有一个关键点是最终结果的累加方式。因为数组下标从0开始,所以找到的位置l对应的元素数量是l+1。但必须再检查一次pc1[l] <= target,因为有可能数组中所有元素都大于target。
为了验证这个算法的正确性,我设计了几组测试用例:
通过这些测试,我确认了算法的正确性。在性能方面,预处理阶段是O(n),查询阶段每个c2位置的处理是O(log m),因此总时间复杂度是O(n log m),其中m是c1的出现次数。对于n=1e5的情况,这个算法能在毫秒级完成计算。
让我们系统性地比较两种解法的优劣:
| 特性 | 暴力解法 | 二分查找优化 |
|---|---|---|
| 时间复杂度 | O(n²) | O(n log n) |
| 空间复杂度 | O(1) | O(n) |
| 编码复杂度 | 简单 | 中等 |
| 适用数据规模 | n ≤ 1e4 | n ≤ 1e6 |
| 优势 | 实现简单 | 处理大数据高效 |
| 劣势 | 大数据量超时 | 需要额外空间 |
在实际比赛中,我的建议是:
这道题的一个变种是统计所有满足条件的子串,而不仅仅是计数。这时暴力解法就完全不适用了,因为输出结果本身可能就是O(n²)规模的。而二分查找优化依然可以工作,只需要在找到符合条件的c1位置后,记录具体的子串即可。
在实现这个算法时,有几个容易出错的地方值得特别注意:
边界条件处理:当k=1时,j-k+1可能等于j,这时要确保不重复计算。在代码中通过if(target < 0) continue来处理k>字符串长度的情况。
整数溢出问题:当n很大时,结果可能超过int的范围。这就是为什么代码中使用了#define int long long。我在一次比赛中就因为没有注意这个问题而丢分。
二分查找的变体:这里使用的是查找最后一个≤target的元素,也可以调整为查找第一个>target的元素,然后减1。两种方式都可以,但要确保逻辑一致。
输入输出效率:在C++中使用ios::sync_with_stdio(0); cin.tie(0);可以显著加快输入输出速度,这对大数据量很重要。
cpp复制// 完整的优化代码示例
#include<bits/stdc++.h>
#define int long long
using namespace std;
void solve() {
int k; string s; char c1, c2;
cin >> k >> s >> c1 >> c2;
vector<int> pc1;
int ans = 0;
for(int i = 0; i < s.size(); i++) {
if(s[i] == c1) pc1.push_back(i);
if(s[i] == c2) {
int target = i - k + 1;
if(target < 0 || pc1.empty()) continue;
int l = 0, r = pc1.size() - 1;
while(l < r) {
int mid = (l + r + 1) >> 1;
if(pc1[mid] <= target) l = mid;
else r = mid - 1;
}
if(pc1[l] <= target) ans += (l + 1);
}
}
cout << ans << endl;
}
signed main() {
ios::sync_with_stdio(0); cin.tie(0);
solve();
return 0;
}
通过这道题,我们可以总结出一些通用的算法优化思路:
我在准备蓝桥杯的过程中,养成了这样的解题习惯:
这种思维模式不仅适用于字符串问题,在动态规划、图论等领域也同样有效。比如在解决最长递增子序列问题时,暴力解法是O(n²),而通过二分查找可以优化到O(n log n)——这与我们这道题的优化思路如出一辙。
为了巩固这种算法思维,我推荐以下几道类似的练习题:
这些题目都体现了"从暴力到优化"的算法演进过程。比如在解决两数之和问题时,暴力解法是双重循环,而优化解法是用哈希表存储已经遍历过的数字,将时间复杂度从O(n²)降到O(n)。
对于想要进一步提高的同学,可以尝试这道题的几个变种:
这些扩展问题会涉及到更复杂的数据结构,如字典树、后缀自动机等,但核心的优化思想是一致的——通过预处理和高效查询来替代暴力枚举。