【基础】整体二分
二分答案的复杂度log(ans),check的复杂度一般至少是O(n)(甚至是nlogn),那么q次询问的复杂度就是 \(O(q*log(ans)*n)\)
q通常与n同规模。如果把询问合并到一起回答,就可以优化复杂度,这也是整体二分的不一样之处。总的来说,对整个ans的值域像线段树一样进行递归,每个区间都有一次check,所以最多有ans*log(ans)次check,并且每次递归最长也是复制所有的查询,最深进行log(ans)次递归。在这里就是 \(O(ans*log(ans)*n+\log(ans)*q)\) 。看起来没有什么区别。但是要注意,在很多问题中ans的取值是跟当前节点的长度有关联的,或者是因为n的除法分块的原因导致最多只有\sqrt(n)种不同的ans。
确切的说,ans如果和n同规模,他们之间可能有什么联系。比如问题是“长度至少为k分一段,n个元素最多分多少段”,显然ans <= n/k,这是一种联系。常见于二分+双指针/滑动窗口/尺取类的题目,从单次二分变成整体二分,这种问题通常跟滑动窗口的窗口大小有关联。下面的“写法一”并不能应付这种情况。详情见 https://codeforces.com/contest/1968/problem/G2 这里的 长度为n的字符串分成k组,也就是枚举lcp的长度为M,lcp的出现次数为cnt,若cnt>=k则k对应的答案至少为len(lcp)。但是k的取值范围(询问次数)为[1, n]。加速的方法其实是,对于k>sqrt(n),由于k是lcp出现的最少次数,所以有k*len(lcp) <= n,也就是len(lcp) <= sqrt(n),这样,如果直接枚举k然后整体二分这一部分,复杂度为 \(O((n - \sqrt{n})*log(ans)*n) = O(n^2*log(ans))\) 不可以,但是如果是枚举lcp的长度得出每个lcp的长度对应的最大的cnt,在这个cnt位置打一个标记,这部分的复杂度为 \(O(\sqrt{n}*n)\) 然后最后统一用dp转移一次k。这种写法和二分就没有什么关系了(试了很久发现其实是白搭的,因为cnt是从1到n,无法通过二分合并cnt相同的区间来加速。需要在更高层的地方进行设计,具体来说就是设定一个查询的阈值CNT,当询问的cnt超过CNT或者不超过CNT时执行暴力解法(在二分的函数中直接return掉,另外调用一个bruteforce来求解(加上dp)),在CNT_THREDHOLD = 0 时(不进行任何暴力)为整体二分,对于有一些问题,比如上述的问题,明知道在cnt > CNT_THREDHOLD 时,虽然有n-sqrt(n)种不同的cnt,但是却至多只有sqrt(n)种答案,这种时候就要使用暴力枚举LCP然后dp。整体的复杂度为二分求解单个查询为 \(O(n*\log(n))\) ,需要使用二分的询问次数为 cnt <= CNT_THREDHOLD,总复杂度为 \(O(n*\log(n)*CNT_THREDHOLD)\)。对于cnt > CNT_THREDHOLD 时,从短到长枚举LCP可能的长度(记为LCP_LEN),每次枚举求出对于这个长度来说能满足的最大需求的cnt,直到能满足的cnt <= CNT_THREDHOLD 之后就break掉了,总复杂度为 \(O(LCP_LEN * n)\)。整个算法的复杂度为 \(O(n*\log(n)*CNT_THREDHOLD + LCP_LEN * n) = O(n*(\log(n)*CNT_THREDHOLD + LCP_LEN))\)
在这道题里,\(CNT_THREDHOLD * LCP_LEN <= n\) 由均值不等式得,$ CNT_THREDHOLD = \sqrt { \frac{n} {\log n}} $ 时有最小值
如果ans和n不同规模,这时候check的复杂度一般就不是O(n)了,可能会有更快的方法,比如区间第k大的值。
高性能版本,空间消耗O(n),通过partition函数来交换两侧的询问,把询问原地交换分成左和右。
写法一(check要尽可能快,不能是O(n)的):
其实根本没必要搞这么复杂的交换,应该是按cnt排序
TODO:根据https://codeforces.com/contest/1968/problem/G2进行优化和封装
namespace MultiBinarySearch {
static const int MAX_QUERY = 2e5 + 10;
struct Query {
int id;
int cnt; // 查询的参数,分cnt组时,每组的大小最大有多大?容易知道分的组数越多,其最大的siz会变小。
} que[MAX_QUERY];
int ans[MAX_QUERY];
int check (int len) {
int cnt = 0;
// 1.实现这个根据M计算cnt的函数。通常复杂度为O(n)
return cnt;
}
// 把[ql, qr]中的询问根据cnt的结果划分,[ql, qm]放在左边,[qm + 1, qr]放在右边
// 好没用的设计,不如在查询的时候按cntsort一下,不再需要交换
int partition (int cnt, int ql, int qr) {
auto is_right = [&] (int qi) {
// 2.根据q.cnt是否能满足cnt的要求,区分是放在右边还是左边
return cnt >= que[qi].cnt;
};
int i = ql, j = qr;
while (i < j) {
while (i < j && !is_right (i)) {
++i;
}
while (i < j && is_right (j)) {
--j;
}
if (i < j) {
swap (que[i], que[j]);
}
}
// i == j
return i - is_right (i);
}
void multi_bs (int L, int R, int ql, int qr) {
if (ql > qr) {
// 一定要加这一句,否则如果可能会复杂度不对
return;
}
if (L == R) {
for (int i = ql; i <= qr; ++i) {
ans[que[i].id] = L;
}
// 注意处理无解的情况
return;
}
int M = (L + R + 1) >> 1; // 3.M 的取值是左偏还是右偏,和下方的check函数还有L和R的移动规则有关。
int cnt = check (M); // 长度为M的LCP,最多能满足cnt组
int qm = partition (cnt, ql, qr);
// 在某些题目中,q.cnt会和值域[L, R]有关联,比如第k大,这时候要注意cnt减去左区间的数量。
multi_bs (L, M - 1, ql, qm);
multi_bs (M, R, qm + 1, qr);
return;
}
};
using namespace MultiBinarySearch;
vector版本,空间消耗O(nlogn),比较好理解。没有这么多复杂的细节。
其实根本没必要搞这么复杂的交换,应该是按cnt排序 TODO
namespace MultiBinarySearch {
static const int MAX_QUERY = 2e5 + 10;
struct Query {
int id, cnt; // 分cnt组时,每组的大小最大有多大?容易知道分的组数越多,其最大的siz会变小。
};
int ans[MAX_QUERY];
int check (int M) {
int cnt = 0;
// 实现这个根据M计算cnt的函数。通常复杂度为O(n)
return cnt;
}
void multi_bs (int L, int R, const vector<Query> &vq) {
if (vq.empty()) {
// 一定要加这一句,否则如果可能会复杂度不对
return;
}
if (L > R) {
return;
}
if (L == R) {
// 注意这里不一定真的有解,取决于一开始二分的时候[L, R]范围内是否保证一定有解
for (auto &q : vq) {
ans[q.id] = L;
}
return;
}
int M = (L + R + 1) >> 1; // M 的取值是左偏还是右偏,和下方的check函数还有L和R的移动规则有关。
int cnt = check (M); // 当猜的答案为M时,最多能分cnt组
vector<Query> vq1, vq2;
// 在某些题目中,q.cnt会和值域[L, R]有关联,比如第k大,这时候要注意cnt减去左区间的数量。
for (auto &q : vq) {
if (cnt >= q.cnt) {
// cnt能满足q的要求,所以q至少为 M,对应 L = M
vq1.push_back (q);
} else {
// cnt不能满足q的要求,所以q至多为 M - 1,对应 R = M - 1
vq2.push_back (q);
}
}
multi_bs (M, R, vq1);
multi_bs (L, M - 1, vq2);
return;
}
};
using namespace MultiBinarySearch;