857. 雇佣 K 名工人的最低成本
- 题目难度Hard
有 N
名工人。 第 i
名工人的工作质量为 quality[i]
,其最低期望工资为 wage[i]
。
现在我们想雇佣 K
名工人组成一个工资组。在雇佣 一组 K 名工人时,我们必须按照下述规则向他们支付工资:
- 对工资组中的每名工人,应当按其工作质量与同组其他工人的工作质量的比例来支付工资。
- 工资组中的每名工人至少应当得到他们的最低期望工资。
返回组成一个满足上述条件的工资组至少需要多少钱。
示例 1:
输入: quality = [10,20,5], wage = [70,50,30], K = 2 输出: 105.00000 解释: 我们向 0 号工人支付 70,向 2 号工人支付 35。
示例 2:
输入: quality = [3,1,10,10,1], wage = [4,8,2,2,7], K = 3 输出: 30.66667 解释: 我们向 0 号工人支付 4,向 2 号和 3 号分别支付 13.33333。
提示:
1 <= K <= N <= 10000
,其中N = quality.length = wage.length
1 <= quality[i] <= 10000
1 <= wage[i] <= 10000
- 与正确答案误差在
10^-5
之内的答案将被视为正确的。
原题地址:
https://leetcode-cn.com/contest/weekly-contest-90/problems/minimum-cost-to-hire-k-workers/
如果还没有做,或者思考时间不到一个小时,最好不要看哦!以免剧透。
算法分析:
一、目标与条件的关系分析
①对工资组中的每名工人,应当按其工作质量quality与同组其他工人的工作质量的比例来支付工资wage。
因为支付给工人的工资必须和工人的工作效率——quality[]成正比,
这意味着选取了K名工人(记其编号为集合S 意为select)后,
支付的总工资 = wage(S)= rate(S)·quality_sum(S),
其中:
,而rate(S)取决于选取的工人集合S,看条件②。
②工资组中的每名工人至少应当得到他们的最低期望工资。
支付给i号的工资必须不少于wage[i],
即
定义每个工人的单位劳动力价格 (即性价比的倒数),
则应取
二、暴力算法思路
由上述分析,求解的目标就是找出某个集合S,使得wage(S)最小并返回。
只需用一个递归:(只做简单分析)
void fun(待选取的工人数量:num,已选取的最大下标:i,quality_sum,rate_max)
FOR( j = i+1 ~ N-1)
递归调用:fun(--num,j,quality_sum +quality[j],max(rate_max,wage[j] /quality[j]));
递归出口:
①当num减为0时,比较表示最终解的全局变量 best和 rate_max*quality_sum
②new_i达到N时也跳出递归。
由于1 <= K <= N <= 10000,所以暴力算法:O(组合数(N,K))的时间复杂度是无法接受的!果断放弃!
三、创造并利用单调性
分析求解目标:总花费 wage(S)= rate(S)·quality_sum(S),
一、如果quality[]整体下降,quality_sum(S)会下降,但rate(S)上升,所以单纯按quality[]贪心是无用的。
二、不难发现wage[]整体下降,则总花费wage(S)的确能下降。那么可以按wage[]先取小再取大的贪心策略吗?
注意条件②,实际上降低S中大部分工人的wage[]是不会影响wage(S)的,只有降低rate[]值最高者(性价比最低者)的wage[]才能降低总花费wage(S)。所以按wage[]的贪心策略实际上受到rate[]值制约。
三、由分析二,注意到rate(S)受S中rate[]值最高者控制,而quality_sum(S)则受全体S控制。
所以尽管集合S有组合数(N,K)种取法,但是★rate(S)至多只有N种取法!取{rate[0],rate[1],...,rate[N-1]}中的一个。
四、所以要对 进行升序排序,利用其单调性大大减少枚举量。
当i≥K时,只要选择S在编号0~i中取(其中i必须取),则rate[i]=rate(S)。
那么quality_sum(S)只需取编号0~i中quality[]值最小的K个工人的下标(其中i必须取),
做法很简单!只要在将quality[0~i]值升序排序,取前K-1个求和+quality[i]即可!
实际上最优解法求quality_sum(S)时间复杂度可以达到O(logK),想想堆排序!
四、O(N²)版本
注意此版本实际上是从rate高往低方向遍历,为了将求quality_sum(S)的时间复杂度从 O(NlogN)减少到O(N)。
其实我应该写个O(N²logN)的版本,就是“三、四”分析中的简单版本,更方便理解!(如果需求可以留言,我有空再改)
typedef int BOOL;
typedef struct SORT{
double rate; //价格:wage/ quality 即性价比的倒数
int quality; //工作效率
BOOL visit; //1是/0否 已经用过(用过==已经遍历完选择此工人能达到的最优解)
//不用bool的原因是:结构体要凑 4 Byte整数倍字长,用BOOL比较可节省硬件开支
SORT(const int & quality, const int & wage)
:rate((double)wage/ quality), quality(quality), visit(0){}
};
//按价格(性价比的倒数)升序(次:工作效率升序)排序
bool rate_cmp(const SORT &a, const SORT &b) {
if (a.rate == b.rate)return a.quality < b.quality;
else return a.rate < b.rate;
}
//按工作效率升序排序(索引排序)
bool q_cmp(const vector<SORT>::iterator &a, const vector<SORT>::iterator &b) {
return a->quality < b->quality;
}
#define INT_MAX 2147483647 // maximum (signed) int value
class Solution {
public:
double mincostToHireWorkers(vector<int>& quality, vector<int>& wage, int K) {
vector<SORT> r_rank; //按价格(性价比的倒数)升序(次:工作效率升序)排序
vector<vector<SORT>::iterator> q_rank; //按工作效率升序排序(索引排序)
size_t N = quality.size();
r_rank.reserve(N); //预留空间
q_rank.reserve(N);
//初始化 r_rank
for (size_t i = 0; i < N; i++){
r_rank.push_back(SORT(quality[i], wage[i]));
}//按价格(性价比的倒数)升序(次:工作效率升序)排序
sort(r_rank.begin(), r_rank.end(), rate_cmp);
//初始化q_rank
for (auto r = r_rank.begin(); r != r_rank.end();++r) {
q_rank.push_back(r);
}//按工作效率升序排序(索引排序)
sort(q_rank.begin(), q_rank.end(), q_cmp);
double best = 1e100; //要找最小,则初值大
int K_1 = K - 1; //要选K名,则所选工人的最大下标至少要 K-1
for (size_t i = N; i-- > K_1;) { //从价格最高(性价比最低)开始遍历
int q_sum = r_rank[i].quality; //求K名未被使用过的工人(包含i号工人)的工作效率总和,要尽可能低。
r_rank[i].visit = 1; //表示r_rank[i]已经用过了
for (size_t j = 0, c = K_1; c > 0; j++) {
if (j >= N) { //(理论上该if不会生效)
return best; //说明未使用过的工人已经凑不够K名了,无需遍历了
}
if (0 == q_rank[j]->visit) { //未用过的
q_sum += q_rank[j]->quality;
--c;
}
}
double wage_sum = q_sum*r_rank[i].rate; //所有人要以最低性价比付工资
if (wage_sum < best)best = wage_sum; //比较最优
}
return best;
}
};
该版本是我一开始写的,当然注释是后来加的(代码有调整)。其实还没写完此版本我就突然想到O(NlogN)版本了。
五、LeetCode竞赛中执行时间最优的大神的解法——O(NlogN)版本
该大神的代码可以在LeetCode中提交了C++版本通过后查看(如下图点击最左边的红条子即可出现)。此处的代码被替换了部分变量名以便与本文的分析采用的命名一致,当然注释也是本人加的。
代码如下:
class Solution {
public:
double mincostToHireWorkers(vector<int>& quality, vector<int>& wage, int K) {
int N = quality.size();
//按照 rate[i] = wage[i]/quality[i](即性价比的倒数)进行索引升序排序的索引向量
vector<int> rate_i(N);
for (int i = 0; i < N; i++) {
rate_i[i] = i;
}
sort(rate_i.begin(), rate_i.end(), [&](const int &a, const int &b) {
return wage[a] * quality[b] < wage[b] * quality[a]; //比值换乘法节省硬件开销
});
int quality_sum = 0; //对应S中元素总和
priority_queue<int> S;
double best = 1e18;
for (auto i : rate_i) {
double rate = (double)wage[i] / quality[i];
quality_sum += quality[i]; //quality_sum对应S中元素总和
S.push(quality[i]);
while (S.size() > K) {
int w = S.top(); S.pop(); //堆顶是最大者,抛弃余下较小者(当前最优)
quality_sum -= w;
}
if (S.size() == K) { //当i∈S时的最优解 与历史最优比较
best = min(best, rate * quality_sum);
}
}
return best;
}
};
六、从“版本四”的O(N²)改进到O(NlogN)版本
此版本是我自己改进的,其实和四号版本只有在求quality_sum(S)时有差别,不可避免的受到定式思维的影响。虽然时间复杂度和大神的一致,但代码比较繁杂,故放在最后,仅供参考。
typedef struct SORT {
double rate; //价格:wage/ quality 即性价比的倒数
int quality; //工作效率
int q_rank; //该工人在quality[]中的排名(quality[]最小的为第0名)
//当q_rank为 INF(无穷大)时,表示已经使用过了(已经遍历完选择此工人能达到的最优解)
SORT(const int & quality, const int & wage) :rate((double)wage / quality), quality(quality) {}
};
//按价格(性价比的倒数)升序(次:工作效率升序)排序
bool rate_cmp(const SORT &a, const SORT &b) {
if (a.rate == b.rate)return a.quality < b.quality;
else return a.rate < b.rate;
}
//按工作效率升序排序(索引排序)
bool quality_cmp(const vector<SORT>::iterator &a, const vector<SORT>::iterator &b) {
return a->quality < b->quality;
}
#define INF 2147483647 // maximum (signed) int value
class Solution {
public:
double mincostToHireWorkers(vector<int>& quality, vector<int>& wage, int K) {
vector<SORT> r_rank; //按价格(性价比的倒数)升序(次:工作效率升序)排序
vector<vector<SORT>::iterator> q_rank; //按工作效率升序排序(索引排序)
size_t N = quality.size();
r_rank.reserve(N); //预留空间
q_rank.reserve(N);
for (size_t i = 0; i < N; i++) {
r_rank.push_back(SORT(quality[i], wage[i]));
} // 按价格(性价比的倒数)升序(次:工作效率升序)排序
sort(r_rank.begin(), r_rank.end(), rate_cmp);
int q_sum = 0, j = K - 1; //求quality_sum使用,时间复杂度可达O(1)
for (auto r = r_rank.begin(); r != r_rank.end(); ++r) {
q_rank.push_back(r);
}//按工作效率升序排序(索引排序)
sort(q_rank.begin(), q_rank.end(), quality_cmp);
for (size_t i = 0; i < N; i++) {
q_rank[i]->q_rank = i;
if (i < K)q_sum += q_rank[i]->quality;
}
//初始的 q_sum 就是quality[0~j]之和,遍历中每用掉q_sum中的一个,就将未使用且未选中的选一个最小的来替补。
double best = 1e100;
int K_1 = K - 1; //要选K名,则所选工人的最大下标至少要 K-1
for (size_t i = N; i-- > K_1;) { //从价格最高(性价比最低)开始遍历
if (r_rank[i].q_rank <= j) { //q_sum 已经包含 r_rank[i].quality
//先比较最优
double wage_sum = q_sum*r_rank[i].rate;
if (wage_sum < best)best = wage_sum;
//∵用掉了r_rank[i],用未使用过的最小的quality替换r_rank[i].quality 更新q_sum
while (++j < N) {
if (q_rank[j]->q_rank <N) { //未被用过
q_sum += q_rank[j]->quality - r_rank[i].quality; //替换
break;
}
}
if (j >= N) { //(理论上该if不会生效)
return best; //说明未使用过的工人已经凑不够K名了,无需遍历了
}
}
else { //q_sum 不含 r_rank[i].quality, 则用 r_rank[i].quality 临时换掉 q_sum中最大的quality
double wage_sum = (q_sum - q_rank[j]->quality + r_rank[i].quality)
*r_rank[i].rate;
if (wage_sum < best)best = wage_sum; //比较最优
}
r_rank[i].q_rank = INF; //表示r_rank[i]已经用过了,不能再选入 q_sum
}
return best;
}
};
如果有什么问题或意见请提出,欢迎大神指正。