文本首发在我的博客 : 分治
- 主定理
- 二分分治
- 四分分治
- CDQ分治
- 相似的场景概念
- 减治
- 枚举所有区间分隔点的搜索问题
- 枚举所有减治方法(方向,长度…)的搜索问题
- 增加记忆化形成减治型区间DP或线性DP
- 514. 自由之路
- 516. 最长回文子序列
$0 主定理
分治的过程
- Divide(分解): 将原问题划分为一些子问题,子问题的形式与原问题一样,只是规模更小
- Conquer(解决): 递归地求解子问题,如果子问题的规模足够小,停止递归,直接解决
- Combine(合并): 将子问题的解组合为原问题的解
算法分析符号
- O O O: 渐进上界
- o o o: 渐进小于
- Ω \Omega Ω: 渐进下界
- Θ \Theta Θ: 既是渐进上界又是渐进下界
递归式
递归式就是一个等式或不等式,通过更小的输入上的函数值来描述一个函数。例如归并排序的递归式:
T ( N ) = { Θ ( 1 ) N = 1 2 T ( N 2 ) + Θ ( N ) N > 1 T(N) = \left\{ \begin{array}{lr} \Theta(1) \quad\quad\quad\quad\quad N = 1 \\ 2T(\frac{N}{2}) + \Theta(N) \quad N > 1 \\ \end{array} \right. T(N)={Θ(1)N=12T(2N)+Θ(N)N>1
求解得 T ( N ) = Θ ( N l o g N ) T(N) = \Theta(NlogN) T(N)=Θ(NlogN)
递归式的形式
一个递归算法可以将问题分成规模不等的子问题,例如分成 1 / 3 1/3 1/3 和 2 / 3 2/3 2/3 两部分。如果分解和合并的步骤都是线性的,则每次递归调用将花费线性时间加上下一层的两次递归调用的时间。递归式: T ( N ) = T ( 2 N / 3 ) + T ( N / 3 ) + Θ ( N ) T(N) = T(2N/3) + T(N/3) + \Theta(N) T(N)=T(2N/3)+T(N/3)+Θ(N)
一个递归算法分成的子问题规模还可以不是原问题规模的一个比例,例如比原问题少一个元素,每次递归调用将花费常数时间再加上下一层递归调用的时间,插入排序就是这种情况。递归式: T ( N ) = T ( N − 1 ) + Θ ( 1 ) T(N) = T(N - 1) + \Theta(1) T(N)=T(N−1)+Θ(1)
一个递归算法将原问题分成若干子问题,还可以只求解其中一个子问题,例如二分算法就是这种情况。每次递归调用将花费常数时间再加上下一层递归调用的时间。递归式: T ( N ) = T ( N / 2 ) + Θ ( 1 ) T(N) = T(N/2) + \Theta(1) T(N)=T(N/2)+Θ(1)
后两种情况都可以视为减治法,即拆分问题后只需要递归求解一个子问题即可得原问题的解。
递归式的求解
- 代入法:猜测是个界,然后用数学法归纳法证明这个界是正确的
- 递归树法:将递归式转换成一棵树,其节点表示不同层次的递归调用产生的代价,然后采用边界和技术(techniques for bounding summations)求解。
- 主方法:可以求解形如 T ( N ) = a T ( N / b ) + f ( N ) T(N) = aT(N/b) + f(N) T(N)=aT(N/b)+f(N) 的递归式的界,这个递归式表示的是这样一种分治算法:生成 a 个子问题,每个子问题规模是原问题规模的 N / b N/b N/b, 分解和合并的步骤花费时间 f ( N ) f(N) f(N)
代入法很简洁,但是好的猜测很难。递归树很适合用于生成好的猜测,之后再用代入法验证该猜测,但生成好的猜测时,需要忍受地点不精确,因为之后才会验证该猜测是否正确。
如果在画递归树和代价求和时非常仔细,就可以用递归树直接证明解是否正确。主方法的基础定理就是使用递归树证明的。
更多细节参考《算法导论》第一部分第4章
不等式型递归式
T ( N ) ≤ a T ( N / b ) + f ( N ) T(N) \leq aT(N/b) + f(N) T(N)≤aT(N/b)+f(N)
递归式描述了 T ( N ) T(N) T(N) 的一个上界,用 O O O 而不是 Θ \Theta Θ 描述其解。
主方法求解递归式
主方法为求解形如 T ( N ) = a T ( N / b ) + f ( N ) T(N) = aT(N/b) + f(N) T(N)=aT(N/b)+f(N) 的递归式的界提供了一种定式化的方法
这个递归式表示的是这样一种分治算法:生成 a 个子问题,每个子问题规模是原问题规模的 N / b N/b N/b, 分解和合并的步骤花费时间 f ( N ) f(N) f(N)
例如描述矩阵乘法 Strassen 算法的递归式中: a = 7, b = 2, f ( N ) = Θ ( N 2 ) f(N) = \Theta(N^{2}) f(N)=Θ(N2)
主定理
主方法依赖于主定理
令 a ≥ 1 , b > 1 a \geq 1, b > 1 a≥1,b>1 为常数, f ( N ) f(N) f(N) 为一个函数, T ( N ) T(N) T(N) 是定义在非负整数上的递归式 T ( N ) = a T ( N / b ) + f ( N ) T(N) = aT(N/b) + f(N) T(N)=aT(N/b)+f(N),其中将 N / b N/b N/b 解释为 ⌊ N / b ⌋ \lfloor N/b \rfloor ⌊N/b⌋ 或 ⌈ N / b ⌉ \lceil N/b \rceil ⌈N/b⌉ 则 T ( N ) T(N) T(N) 有如下渐进界
- 若对某个常数 ϵ > 0 \epsilon > 0 ϵ>0 有 f ( N ) = O ( N l o g b a − ϵ ) f(N) = O(N^{log_{b}a-\epsilon}) f(N)=O(Nlogba−ϵ),则 T ( N ) = Θ ( N l o g b a ) T(N) = \Theta(N^{log_{b}a}) T(N)=Θ(Nlogba)
- 若 f ( N ) = Θ ( N l o g b a ) f(N) = \Theta(N^{log_{b}a}) f(N)=Θ(Nlogba),则 T ( N ) = Θ ( N l o g b a l o g N ) T(N) = \Theta(N^{log_{b}a}logN) T(N)=Θ(NlogbalogN)
- 若对某个常数 ϵ > 0 \epsilon > 0 ϵ>0 有 f ( N ) = Ω ( N l o g b a − ϵ ) f(N) = \Omega(N^{log_{b}a-\epsilon}) f(N)=Ω(Nlogba−ϵ),且对某个常数 c < 1 c < 1 c<1 和所有足够大的 N 有 a f ( N / b ) ≤ c f ( N ) af(N/b) \leq cf(N) af(N/b)≤cf(N),则 T ( N ) = Θ ( f ( N ) ) T(N) = \Theta(f(N)) T(N)=Θ(f(N))
定理三种情况的含义是比较 f ( N ) f(N) f(N) 和 N l o g b a N^{log_{b}a} Nlogba,情况 1~3 分别对应上述比较的渐进小于,渐进等于,渐进大于。
这三种情况并未覆盖 f ( N ) f(N) f(N) 的所有可能性,例如 f ( N ) f(N) f(N) 小于 N l o g b a N^{log_{b}a} Nlogba,但并不是渐进小于。未覆盖的情况,不能使用主方法求解递归式。
证明和使用方法参考《算法导论》第一部分第4章
$1 二分分治
53. 最大子序和
分治法的意义:可以处理动态询问 [i, j] 的最大子段和,可以把分治过程中子区间的最大子段和维护在线段树上,线段树上隐含了分治算法
[线段树-分治+区间合并]区间最大子段和有三种情况:
- 左子树最大子段和
- 右子树最大子段和
- 左子树的最大后缀和 + 右子树的最大前缀和
23. 合并K个升序链表
合并 K 个链表问题,分解为两个合并 K/2 个链表的问题
- 分解:将 K 个链表平均分成两份
- 解决:当 K = 1 时可以直接解决,即返回该链表
- 合并:合并 2 个链表的问题
…
169. 多数元素
- 分解:将数组分为左右两部分
- 解决:当数组长度为 1 时,可以直接解决,即众数为数组中的一个元素
- 合并:左边的众数为 x,右边的众数为 y,若 x 和 y 不同,则需要遍历区间决定谁才是整个区间上真正的众数
算法的正确性依赖多数元素的个数大于数组元素个数的一半这个条件。
932. 漂亮数组
对于问题 A[l…r]:
- 分解:将 A[l+2i] 放进左半边,将 A[l+2i+1] 放进右半边
- 解决:当 r = l 时,可以直接解决,即返回 A[l]
- 合并:将左边的结果 left 和右边结果 right 合并
参考
$2 四分分治
1274. 矩形内船只的数目
class Solution {
public:
//
// [x2, y2]
//
// [x1, y1]
int countShips(Sea sea, vector<int> topRight, vector<int> bottomLeft) {
int x1 = bottomLeft[0], x2 = topRight[0];
int y1 = bottomLeft[1], y2 = topRight[1];
return _countShips(sea, x1, x2, y1, y2);
}
private:
int _countShips(Sea sea, int x1, int x2, int y1, int y2)
{
// cout << x1 << " " << x2 << " " << y1 << " " << y2 << endl;
// 调用方保证 x1 <= x2, y1 <= y2
if(!sea.hasShips(vector<int>({x2, y2}), vector<int>({x1, y1})))
return 0;
if(x1 == x2 && y1 == y2)
return 1;
int midx = (x1 + x2) / 2;
int midy = (y1 + y2) / 2;
int topleft = 0;
int topright = 0;
int bottomleft = 0;
int bottomright = 0;
topleft = _countShips(sea, x1, midx, y1, midy);
if(y1 < y2)
topright = _countShips(sea, x1, midx, midy + 1, y2);
if(x1 < x2)
bottomleft = _countShips(sea, midx + 1, x2, y1, midy);
if(x1 < x2 && y1 < y2)
bottomright = _countShips(sea, midx + 1, x2, midy + 1, y2);
return topleft + topright + bottomleft + bottomright;
}
};
参考
$3 CDQ分治
315. 计算右侧小于当前元素的个数
参考
$4 相近的场景和概念
(1) 减治
向下递归时只需要进入一个子问题的场景,例如
- topK: topK问题分类汇总
- 二分:二分
4. 寻找两个正序数组的中位数
…
240. 搜索二维矩阵 II
class Solution {
public:
bool searchMatrix(vector<vector<int>>& matrix, int target) {
if(matrix.empty()) return false;
int n = matrix.size(), m = matrix[0].size();
int x = 0, y = m - 1;
while(y >= 0 && x < n)
{
if(matrix[x][y] == target)
return true;
else if(matrix[x][y] > target)
--y;
else
++x;
}
return false;
}
};
1545. 找出第 N 个二进制字符串中的第 K 位
class Solution {
public:
char findKthBit(int n, int k) {
if(n == 1)
return '0';
ll mid = pow(2, n - 1) - 1;
if((ll)(k - 1) == mid) return '1';
else if((ll)(k - 1) < mid)
return findKthBit(n - 1, k);
else
return flip(findKthBit(n - 1, (pow(2, n) - 2 - (k - 1)) + 1));
}
private:
using ll = long long;
char flip(const char& ch)
{
if(ch == '1')
return '0';
else
return '1';
}
};
215. 数组中的第K个最大元素
(2) 枚举所有区间分隔点的搜索问题
枚举所有可能的拆分为两个子问题的分隔点,对于每个分隔点,解决两个子问题后合并答案形成原问题的答案。
目标是寻找哪个分隔点形成的原问题答案是最好的。
这不是分解子问题再合并答案了,而是问那种分解子问题的方法最好合并的答案最好,是搜索问题了。
面试题 08.14. 布尔运算
241. 为运算表达式设计优先级
分治型区间DP
需要枚举所有拆分子问题的分隔点寻找哪个分隔点合并出的答案最好,是搜索问题
如果对每个分隔点的结果增加记忆化,形成一种区间DP,我管它叫分治型区间 DP
282. 给表达式添加运算符
1130. 叶值的最小代价生成树
struct Result
{
int val; // 节点值
int sum; // 子树各个节点值之和
int max_leaf;
Result(int val, int sum, int max_leaf):val(val),sum(sum),max_leaf(max_leaf){}
};
class Solution {
public:
int mctFromLeafValues(vector<int>& arr) {
int n = arr.size();
int sum = 0;
for(int i: arr)
sum += i;
dp = vector<vector<Result>>(n, vector<Result>(n, Result(-1, -1, -1)));
Result result = solve(arr, 0, n - 1);
return result.sum - sum;
}
private:
vector<vector<Result>> dp;
Result solve(const vector<int>& arr, int l, int r)
{
if(l == r)
return Result(arr[l], arr[l], arr[l]);
if(dp[l][r].val != -1)
return dp[l][r];
// l < r
Result result(-1, INT_MAX, -1);
for(int mid = l; mid < r; ++mid)
{
Result left = solve(arr, l, mid);
Result right = solve(arr, mid + 1, r);
int val = left.max_leaf * right.max_leaf;
int max_leaf = max(left.max_leaf, right.max_leaf);
int sum = val + left.sum + right.sum;
if(sum < result.sum)
{
result.val = val;
result.max_leaf = max_leaf;
result.sum = sum;
}
}
return dp[l][r] = result;
}
};
903. DI 序列的有效排列
…
(3) 枚举所有减治方法(方向,长度…)的搜索问题
分治问题的分隔点可能有多种选择,当需要枚举每种选择寻找哪种分隔点合并后的答案最好时,成为搜索问题,可以增加记忆化成为区间DP
减治问题的减治方法也可能有多个,当需要枚举每种减治方法寻找那个方向的结果最好时,也成为搜索问题。这种搜索问题也可以增加记忆化形成DP问题。
减治问题减治方法的不同可能有两种因素:
第一种是减治的个数,例如跳台阶问题,每次可条一步或两步,那么 1~N 的问题就可能减治为 1~N-1 或者 1~N-2 这两种,这种情况增加记忆化形成线性DP。
第二种是减治的方向,例如回文子序列问题,当左右端点不相等时,1~N 的问题可能减治为 1~N-1 或者 2~N 这两种,这种情况增加记忆化形成另一种区间DP,我管它叫减治型区间DP
514. 自由之路
形成线性DP
class Solution {
public:
int findRotateSteps(string ring, string key) {
int n = ring.size();
vector<vector<int>> mapping(26); // ring 中 r -> idxs
for(int i = 0; i < n; ++i)
{
mapping[ring[i] - 'a'].push_back(i);
}
// dp[i][j] := 当前在 key[i], ring[j] 的最少步数
int m = key.size();
vector<vector<int>> dp(n, vector<int>(m + 1, -1));
for(int i = 0; i < n; ++i)
dp[i][m] = 0;
return solve(0, 0, key, ring, mapping, dp);
}
private:
int solve(int i, int j, const string& key, const string& ring, const vector<vector<int>>& mapping, vector<vector<int>>& dp)
{
if(dp[i][j] != -1) return dp[i][j];
int n = ring.size();
// 当前在 ring[i], 输入 key[j..m-1] 所需步数
int ans = INT_MAX;
for(int nxt: mapping[key[j] - 'a'])
{
int step = min((n + i - nxt) % n, (n + nxt - i) % n);
ans = min(ans, step + 1 + solve(nxt, j + 1, key, ring, mapping, dp));
}
return dp[i][j] = ans;
}
};
516. 最长回文子序列
形成减治型区间DP
class Solution {
public:
int longestPalindromeSubseq(string s) {
if(s.empty()) return 0;
int n = s.size();
if(n == 1) return 1;
vector<vector<int> > dp(n, vector<int>(n, 0));
for(int i = 0; i < n; ++i)
dp[i][i] = 1;
for(int j = 1; j < n; ++j)
for(int i = j - 1; i >= 0; --i)
{
if(s[i] == s[j])
dp[i][j] = dp[i + 1][j - 1] + 2;
else
dp[i][j] = max(dp[i + 1][j], dp[i][j - 1]);
}
return dp[0][n - 1];
}
};