这道题目来自华东师范大学2022年的机试编程题,考察的是对大规模数据处理和二分查找算法的综合应用能力。题目要求我们处理两个数组的乘积矩阵,并找出排序后的第k大元素。
给定两个数组a和b,长度分别为n和m(n,m≤10^5),我们需要构造一个n×m的矩阵c,其中c[i][j]=a[i]×b[j]。然后将这个矩阵中的所有元素从大到小排序,找出第k大的元素值。
最直观的解法是计算出所有n×m个乘积,排序后取第k个。但当n和m都是10^5时,乘积矩阵将有10^10个元素,这显然无法在合理时间内完成计算和排序。因此,我们需要寻找更高效的算法。
直接计算所有乘积并排序的时间复杂度是O(nm log(nm)),当n和m都是10^5时,nm=10^10,这样的复杂度完全不可接受。我们需要一个时间复杂度更低的算法。
观察到我们可以将问题转化为:找到一个数x,使得矩阵中大于等于x的元素数量恰好为k。这提示我们可以使用二分查找来解决这个问题。
具体思路:
二分查找的时间复杂度是O(log(max_val - min_val)),其中max_val和min_val是可能的最大和最小乘积值。对于每个mid值,我们需要O(n log m)的时间来计算有多少元素大于等于mid。因此总时间复杂度是O(n log m log(max_val - min_val)),这在n和m都是10^5时是可接受的。
首先需要对两个数组进行排序,这是为了后续能够高效地计算每行中满足条件的元素数量。
cpp复制sort(a + 1, a + 1 + n); // 排序保持单调性
sort(b + 1, b + 1 + m);
设置初始的查找范围为可能的最小乘积和最大乘积:
cpp复制int l = -1e12, r = 1e12; // 1e6 * 1e6
while(l < r){
int mid = l + r >> 1;
if(check(mid)) r = mid; // 看 mid 实际在 n * m 里面能排 rk 几和 k 比较
else l = mid + 1;
}
check函数需要计算矩阵中有多少元素大于mid。由于数组已排序,我们可以对每行使用二分查找:
cpp复制bool check(int x){
int sum = 0;
for(int i = 1; i <= n; i ++){
if(a[i] < 0){ // 单调递减
int l = 1, r = m;
while(l < r){
int mid = (l + r + 1) >> 1;
if(a[i] * b[mid] <= x) r = mid - 1;
else l = mid;
}
// 考虑边界
if(l == 1 && a[i] * b[l] <= x) l = 0;
sum += m - l;
}else{ // >= 0 单调递增
int l = 1, r = m;
while(l < r){
int mid = l + r >> 1;
if(a[i] * b[mid] <= x) l = mid + 1;
else r = mid;
}
// 考虑边界
if(l == m && a[i] * b[l] <= x) l = m + 1;
sum += l - 1;
}
}
int rk = n * m - sum + 1;
return rk <= k;
}
特别注意当a[i]为正数或负数时,乘积的单调性会发生变化:
这导致我们需要针对这两种情况分别处理二分查找的逻辑。
cpp复制#include<bits/stdc++.h>
using namespace std;
#define int long long
const int N = 1e5 + 10;
int a[N], b[N];
int n, m, k;
bool check(int x){
int sum = 0;
for(int i = 1; i <= n; i ++){
if(a[i] < 0){ // 单调递减
int l = 1, r = m;
while(l < r){
int mid = (l + r + 1) >> 1;
if(a[i] * b[mid] <= x) r = mid - 1;
else l = mid;
}
if(l == 1 && a[i] * b[l] <= x) l = 0;
sum += m - l;
}else{ // >= 0 单调递增
int l = 1, r = m;
while(l < r){
int mid = l + r >> 1;
if(a[i] * b[mid] <= x) l = mid + 1;
else r = mid;
}
if(l == m && a[i] * b[l] <= x) l = m + 1;
sum += l - 1;
}
}
int rk = n * m - sum + 1;
return rk <= k;
}
void solve(){
cin >> n >> m >> k;
for(int i = 1; i <= n; i ++) cin >> a[i];
for(int i = 1; i <= m; i ++) cin >> b[i];
sort(a + 1, a + 1 + n);
sort(b + 1, b + 1 + m);
int l = -1e12, r = 1e12;
while(l < r){
int mid = l + r >> 1;
if(check(mid)) r = mid;
else l = mid + 1;
}
cout << l << endl;
}
signed main(){
int T = 1;
while(T --){
solve();
}
return 0;
}
使用更快的IO:对于大规模数据输入,可以使用更快的IO方法如scanf/printf或关闭同步的cin/cout。
提前终止:在check函数中,如果已经确定sum超过某个阈值,可以提前终止循环。
并行处理:对于多核处理器,可以考虑将不同行的计算分配到不同线程中并行处理。
只需要存储两个数组,空间复杂度为O(n + m)
可以通过以下测试用例验证算法正确性:
code复制3 3 3
2 3 4
4 5 6
预期输出:18
code复制2 2 3
-1 2
-3 4
预期输出:-3
code复制1 1 1
1000000
1000000
预期输出:1e12
整数溢出:乘积可能达到1e12,需要使用long long类型。
二分查找边界条件:特别注意当a[i]为0时的处理,以及查找边界的情况。
排序方向:确保排序方向正确,否则会影响二分查找的逻辑。
打印中间结果:在二分查找过程中打印l、r和mid的值,帮助理解查找过程。
小规模测试:先用小规模数据测试,确保基本逻辑正确。
边界测试:特别测试k=1和k=n*m的情况,以及数组包含0和负数的情况。
在实际编程竞赛和面试中,这类问题经常出现,掌握这种二分查找的高级应用技巧非常重要。通过这道题,我们不仅学习了一个具体问题的解法,更重要的是掌握了处理大规模数据排序和查找问题的通用思路。