1. 分治算法基础与核心思想
分治算法(Divide and Conquer)是计算机科学中一种经典的问题解决范式,其核心思想可以概括为三个步骤:分解(Divide)、解决(Conquer)、合并(Combine)。这种算法设计策略特别适合处理大规模数据问题,能够将复杂问题拆解为多个相同或相似的子问题,直到子问题足够简单可以直接求解。
在实际编码面试和算法竞赛中,分治算法经常被用于解决以下几类典型问题:
- 数组排序(如归并排序、快速排序)
- 统计逆序对数量
- 计算右侧小于当前元素的个数
- 查找数组中的翻转对
提示:分治算法的时间复杂度分析通常使用主定理(Master Theorem),对于规模为n的问题,若每次将其分为a个子问题,每个子问题规模为n/b,则时间复杂度可表示为T(n) = aT(n/b) + f(n)。
1.1 归并排序的分治实现
归并排序是理解分治算法最直观的例子。让我们看一个C++实现示例:
cpp复制void mergeSort(vector<int>& nums, int left, int right) {
if (left >= right) return;
int mid = left + (right - left) / 2;
mergeSort(nums, left, mid); // 分治左半部分
mergeSort(nums, mid + 1, right); // 分治右半部分
merge(nums, left, mid, right); // 合并两个有序数组
}
void merge(vector<int>& nums, int left, int mid, int right) {
vector<int> temp(right - left + 1);
int i = left, j = mid + 1, k = 0;
while (i <= mid && j <= right) {
if (nums[i] <= nums[j]) temp[k++] = nums[i++];
else temp[k++] = nums[j++];
}
while (i <= mid) temp[k++] = nums[i++];
while (j <= right) temp[k++] = nums[j++];
for (int p = 0; p < temp.size(); p++) {
nums[left + p] = temp[p];
}
}
这个实现展示了分治算法的典型结构:递归地将问题分解(数组分成两半),分别解决子问题(对子数组排序),然后合并结果(合并两个有序子数组)。
2. 交易逆序对问题解析
交易逆序对问题(Counting Inversions)是分治算法应用的经典案例。给定一个数组,逆序对定义为数组中满足i < j且nums[i] > nums[j]的二元组(i, j)。统计逆序对数量在金融交易分析、数据相似性比较等领域有实际应用。
2.1 基于归并排序的逆序对统计
传统的暴力解法需要O(n²)时间复杂度,而利用分治思想可以在O(nlogn)时间内解决问题。关键是在归并排序的合并过程中统计逆序对数量。
cpp复制int countInversions(vector<int>& nums) {
return mergeSortAndCount(nums, 0, nums.size() - 1);
}
int mergeSortAndCount(vector<int>& nums, int left, int right) {
if (left >= right) return 0;
int mid = left + (right - left) / 2;
int count = mergeSortAndCount(nums, left, mid) +
mergeSortAndCount(nums, mid + 1, right);
vector<int> temp(right - left + 1);
int i = left, j = mid + 1, k = 0;
while (i <= mid && j <= right) {
if (nums[i] <= nums[j]) {
temp[k++] = nums[i++];
} else {
temp[k++] = nums[j++];
count += mid - i + 1; // 关键统计步骤
}
}
while (i <= mid) temp[k++] = nums[i++];
while (j <= right) temp[k++] = nums[j++];
for (int p = 0; p < temp.size(); p++) {
nums[left + p] = temp[p];
}
return count;
}
注意:在合并过程中,当右半部分的元素被选中放入临时数组时,左半部分剩余的所有元素都与该右半部分元素构成逆序对,这就是count += mid - i + 1的统计逻辑。
2.2 实际应用场景与变种
交易逆序对问题在实际中有多种应用场景:
- 金融领域:分析交易序列中的异常模式
- 推荐系统:衡量用户偏好序列的相似度
- 基因组学:测量DNA序列的相似性
一个常见的变种问题是计算"重要逆序对",即满足nums[i] > 2*nums[j]的逆序对。这类问题可以通过调整合并阶段的比较条件来解决。
3. 计算右侧小于当前元素的个数
LeetCode第315题"计算右侧小于当前元素的个数"是逆序对问题的扩展版本。给定一个整数数组nums,要求返回一个新数组counts,其中counts[i]表示在nums[i]右侧且比nums[i]小的元素数量。
3.1 分治解法实现
这个问题可以看作是"为每个元素统计其右侧逆序对数量"。我们可以在归并排序过程中,为每个元素维护其右侧小于它的元素个数。
cpp复制vector<int> countSmaller(vector<int>& nums) {
vector<int> counts(nums.size(), 0);
vector<pair<int, int>> indexedNums; // 保存元素值和原始索引
for (int i = 0; i < nums.size(); i++) {
indexedNums.emplace_back(nums[i], i);
}
mergeSortAndCount(indexedNums, 0, nums.size() - 1, counts);
return counts;
}
void mergeSortAndCount(vector<pair<int, int>>& indexedNums, int left, int right, vector<int>& counts) {
if (left >= right) return;
int mid = left + (right - left) / 2;
mergeSortAndCount(indexedNums, left, mid, counts);
mergeSortAndCount(indexedNums, mid + 1, right, counts);
vector<pair<int, int>> temp(right - left + 1);
int i = left, j = mid + 1, k = 0;
while (i <= mid && j <= right) {
if (indexedNums[i].first <= indexedNums[j].first) {
counts[indexedNums[i].second] += j - mid - 1;
temp[k++] = indexedNums[i++];
} else {
temp[k++] = indexedNums[j++];
}
}
while (i <= mid) {
counts[indexedNums[i].second] += right - mid;
temp[k++] = indexedNums[i++];
}
while (j <= right) {
temp[k++] = indexedNums[j++];
}
for (int p = 0; p < temp.size(); p++) {
indexedNums[left + p] = temp[p];
}
}
3.2 算法优化与边界处理
在实际实现中,有几个关键点需要注意:
- 需要保留元素的原始索引,以便正确更新counts数组
- 当左半部分元素被选中时,当前右半部分已处理的所有元素都比它小
- 处理剩余左半部分元素时,整个右半部分都比它们小
这个算法的时间复杂度为O(nlogn),空间复杂度为O(n),相比暴力解法的O(n²)有了显著提升。
4. 翻转对问题深度解析
翻转对问题(Reverse Pairs)是逆序对问题的加强版,要求统计满足i < j且nums[i] > 2*nums[j]的二元组数量。这个问题在LeetCode第493题中出现,是分治算法应用的又一典型案例。
4.1 分治解法实现
与普通逆序对不同,翻转对需要在合并前单独统计满足nums[i] > 2*nums[j]的对数。这需要在归并排序框架中添加一个额外的统计步骤。
cpp复制int reversePairs(vector<int>& nums) {
return mergeSortAndCount(nums, 0, nums.size() - 1);
}
int mergeSortAndCount(vector<int>& nums, int left, int right) {
if (left >= right) return 0;
int mid = left + (right - left) / 2;
int count = mergeSortAndCount(nums, left, mid) +
mergeSortAndCount(nums, mid + 1, right);
// 统计翻转对数量
int i = left, j = mid + 1;
while (i <= mid && j <= right) {
if ((long)nums[i] > 2 * (long)nums[j]) {
count += mid - i + 1;
j++;
} else {
i++;
}
}
// 常规归并排序合并过程
vector<int> temp(right - left + 1);
i = left, j = mid + 1;
int k = 0;
while (i <= mid && j <= right) {
if (nums[i] <= nums[j]) temp[k++] = nums[i++];
else temp[k++] = nums[j++];
}
while (i <= mid) temp[k++] = nums[i++];
while (j <= right) temp[k++] = nums[j++];
for (int p = 0; p < temp.size(); p++) {
nums[left + p] = temp[p];
}
return count;
}
4.2 关键点与常见错误
实现翻转对统计时容易犯的几个错误:
- 整数溢出:当nums[i]和nums[j]都很大时,2*nums[j]可能溢出,需要转换为long类型
- 统计顺序:必须在合并前完成翻转对的统计,因为合并会改变元素的相对位置
- 双指针技巧:统计时使用双指针法可以在O(n)时间内完成,保持整体O(nlogn)复杂度
提示:对于大规模数据,分治算法的优势更加明显。当n=1e5时,O(n²)算法可能需要数小时,而O(nlogn)算法只需几毫秒。
5. 分治算法的扩展应用
分治算法不仅适用于上述问题,还可以解决许多其他计算问题。理解分治思想的本质后,可以将其应用到各种场景中。
5.1 线段树与树状数组解法
虽然分治方法优雅高效,但在某些场景下,线段树或树状数组可能是更好的选择,特别是需要频繁更新数据的情况。这些数据结构同样基于分治思想,但支持动态更新。
cpp复制// 线段树实现统计右侧小于当前元素个数的示例
class SegmentTree {
private:
vector<int> tree;
int n;
void update(int index, int val, int node, int left, int right) {
if (left == right) {
tree[node] += val;
return;
}
int mid = left + (right - left) / 2;
if (index <= mid) update(index, val, 2 * node + 1, left, mid);
else update(index, val, 2 * node + 2, mid + 1, right);
tree[node] = tree[2 * node + 1] + tree[2 * node + 2];
}
int query(int ql, int qr, int node, int left, int right) {
if (ql > right || qr < left) return 0;
if (ql <= left && qr >= right) return tree[node];
int mid = left + (right - left) / 2;
return query(ql, qr, 2 * node + 1, left, mid) +
query(ql, qr, 2 * node + 2, mid + 1, right);
}
public:
SegmentTree(int size) : n(size), tree(4 * size, 0) {}
void update(int index, int val) {
update(index, val, 0, 0, n - 1);
}
int query(int ql, int qr) {
return query(ql, qr, 0, 0, n - 1);
}
};
vector<int> countSmaller(vector<int>& nums) {
// 坐标离散化
set<int> sorted(nums.begin(), nums.end());
unordered_map<int, int> ranks;
int rank = 0;
for (int num : sorted) ranks[num] = rank++;
vector<int> counts(nums.size(), 0);
SegmentTree st(ranks.size());
for (int i = nums.size() - 1; i >= 0; --i) {
int r = ranks[nums[i]];
counts[i] = st.query(0, r - 1);
st.update(r, 1);
}
return counts;
}
5.2 分治与其他算法的结合
在实际应用中,分治算法常与其他算法范式结合使用:
- 分治+动态规划:解决最优子结构问题
- 分治+贪心:某些最优化问题
- 分治+哈希:加速子问题处理
例如,在解决"根号分治"问题时,我们根据问题规模选择不同的策略,小规模时使用暴力法,大规模时使用分治法,这是分治思想的灵活应用。
6. 性能比较与工程实践
在实际工程实现中,选择合适的分治算法实现需要考虑多种因素,包括时间复杂度、空间复杂度、实现复杂度以及具体应用场景的特殊需求。
6.1 各种解法的性能对比
| 问题类型 | 暴力解法复杂度 | 分治解法复杂度 | 线段树解法复杂度 |
|---|---|---|---|
| 逆序对统计 | O(n²) | O(nlogn) | O(nlogn) |
| 右侧小于当前元素个数 | O(n²) | O(nlogn) | O(nlogn) |
| 翻转对统计 | O(n²) | O(nlogn) | O(nlogn) |
虽然分治和线段树的理论复杂度相同,但实际性能有差异:
- 分治实现通常更简单,常数因子更小
- 线段树支持动态更新,适合数据流场景
- 树状数组实现更简洁,但适用场景有限
6.2 工程实现中的优化技巧
在实现分治算法时,可以采用以下优化技巧:
- 小规模数据切换:当子问题规模足够小时(如n<20),切换到插入排序等简单算法
- 内存预分配:预先分配临时数组,避免频繁内存分配
- 并行处理:独立子问题可以并行计算,充分利用多核CPU
- 迭代实现:对于深度大的递归,可以改为迭代实现避免栈溢出
例如,优化后的归并排序可以这样实现:
cpp复制const int INSERTION_SORT_THRESHOLD = 16;
void optimizedMergeSort(vector<int>& nums) {
vector<int> temp(nums.size()); // 预分配临时数组
for (int size = 1; size < nums.size(); size *= 2) {
for (int left = 0; left < nums.size(); left += 2 * size) {
int mid = min(left + size - 1, (int)nums.size() - 1);
int right = min(left + 2 * size - 1, (int)nums.size() - 1);
if (right - left + 1 <= INSERTION_SORT_THRESHOLD) {
insertionSort(nums, left, right);
} else {
merge(nums, temp, left, mid, right);
}
}
}
}
void insertionSort(vector<int>& nums, int left, int right) {
for (int i = left + 1; i <= right; i++) {
int key = nums[i];
int j = i - 1;
while (j >= left && nums[j] > key) {
nums[j + 1] = nums[j];
j--;
}
nums[j + 1] = key;
}
}
这种实现结合了分治和插入排序的优点,在实际应用中性能更好。
