寒假讲课Day 11:二分法、分治法
二分法、分治法
二分法
二分查找
问题引入:猜数游戏
已知\(l,r\in\mathbb{Z},\ l\leq r\),我指定一个整数\(x^*\in[l,r]\)让你猜。你每次可以猜一个数\(x\),然后我会告诉你\(x\)与\(x^*\)的大小关系。最坏情况下,你最少要几次才能猜出\(x^*\)?换句话说,猜数的最优策略是怎样的?
显然最优策略是每次猜可能范围中央的那个值,逐渐缩小范围,最坏需要\(\lceil\log_2(r-l+1)\rceil\)次。这里最关键的就是可以知道估计值和准确值的“大小关系”。
让我们抽象一层:
给定单调递增函数\(f:\mathbb{Z}\cap[l,r]\to\mathbb{R}\),指定一个\(y^*\in\operatorname{ran}f\),求\(x^*\in\mathbb{Z}\cap[l,r]\)使\(f(x^*)=y^*\)。
继续抽象一层:
给定有限全序集\(\langle S,\leq\rangle\),我指定一个\(x^*\in S\)让你猜,你每次可以猜一个数\(x\),然后我会告诉你\(x\)与\(x^*\)的大小关系。最坏情况下,你最少要几次才能猜出\(x^*\)?
方法描述
二分法的关键在于有序性。特别地,有序通常表现为单调性。
一般有三种查找方式:
- 找到任何一个\(x^*\)使\(f(x^*)=y^*\)。
- 找到最小的\(x^*\)使\(f(x^*)\geq y^*\)。
- 找到最小的\(x^*\)使\(f(x^*)>y^*\)。
例题
STL
注意到这个逻辑很容易抽象出来,所以标准库其实为我们提供了好用的库函数。
std::binary_search:二分查找返回是否存在目标值。废物。std::lower_bound:返回最小的能插入目标元素而不破坏有序性的位置。实际上是最小的大于等于目标元素的位置。std::upper_bound:返回最大的能插入目标元素而不破坏有序性的位置。实际上是最小的大于目标元素的位置。
Python也有类似的bisect库。神秘的是,Java似乎只提供了“找到任何一个”的函数。
二分答案
大部分时候,由于时空复杂度限制,我们没办法将整个函数保存为数组再进行二分查找。由于二分查找过程中,我们仅使用了极少的几个位置上的值(\(\lceil\log_2n\rceil\)个),所以可以临时计算这些位置的函数值。这是“二分答案”的实现基础。
对于一些题目,我们可以考虑枚举答案然后检验正确性。如果检验的函数是满足单调性的,就可以使用二分法。特别地,如果答案的一侧满足条件而另一侧不满足,也可以视作一个单调的bool向量。
二分法并不局限于整数集。对于实数上的二分,注意循环条件应用\(r-l\geq\varepsilon\)。
典型例题
模板
/**
* @brief behaves as if it generates func[beg, end) and
* performs std::lower_bound on it
*/
template <typename T, typename Ret, typename Func,
typename Cmp = std::less<Ret>>
inline T lowerBound(T beg, T end, const Ret &val,
Func func = Func(), Cmp cmp = Cmp()) {
while (beg < end) {
T mid = beg + (end - beg) / 2;
if (cmp(func(mid), val)) {
beg = mid + 1;
} else {
end = mid;
}
}
return beg;
}
/**
* @brief behaves as if it generates func[beg, end) and
* performs std::upper_bound on it
*/
template <typename T, typename Ret, typename Func,
typename Cmp = std::less<Ret>>
inline T upperBound(T beg, T end, const Ret &val,
Func func = Func(), Cmp cmp = Cmp()) {
while (beg < end) {
T mid = beg + (end - beg) / 2;
if (cmp(val, func(mid))) {
end = mid;
} else {
beg = mid + 1;
}
}
return beg;
}
分治法
定义
分治(divide and conquer),字面上的解释是「分而治之」,就是把一个复杂的问题分成两个或更多的相同或相似的子问题,直到最后子问题可以简单的直接求解,原问题的解即子问题的解的合并。
分治法是“子问题思想”下的另一种方法论。二分法可以视作是一种特殊的分治。
适用条件
- 该问题的规模缩小到一定的程度就可以容易地解决。
- 该问题可以分解为若干个规模较小的相同问题,即该问题具有最优子结构性质,利用该问题分解出的子问题的解可以合并为该问题的解。
- 该问题所分解出的各个子问题是相互独立的,即子问题之间不包含公共的子问题。
典型例题
归并排序
有时间就讲一下罢。
定义了全序关系的集合可以排序。“排序”这个操作完全符合分治的条件:
- 排序一个或两个元素的序列是很容易的。
- 将一个序列排序,可以将其分解为分别排序前后两半部分,然后利用双指针合并为一个有序序列。
template <typename OutputIter, typename AuxIter>
void mergeSort(OutputIter first, OutputIter last, AuxIter aux) {
size_t n = std::distance(first, last);
if (n <= 1) return;
// divide and conquer
auto mid = std::next(first, n >> 1);
mergeSort(first, mid, aux);
mergeSort(mid, last, aux);
// merge
auto i = first, j = mid, k = aux;
while (i != mid && j != last) {
if (*i > *j) {
*(k++) = *(j++);
} else {
*(k++) = *(i++);
}
}
while (i != mid) *(k++) = *(i++);
while (j != last) *(k++) = *(j++);
std::copy(aux, k, first);
}

浙公网安备 33010602011771号