【算法】Leetcode1125 - 4种解法的对比分析 - 动态规划 & DFS & 位压缩

题目

Leetcode145周赛的第四题

Input: req_skills = ["java","nodejs","reactjs"], people = [["java"],["nodejs"],["nodejs","reactjs"]]
Output: [0,2]

当时觉得应该就是动规,但没想出来动规怎么做。
结束后观摩各路大神的做法基本就是遍历动规DFS,DFS基本要比遍历动规快,同时动规不同的写法速度差异很大。
总结学习下来,应该算是背包问题的加强版,多了用bit位压缩存储技能状态。
因为背包问题基本就记得这个名字了,所以干脆趁此记录一下。
.

Solution 遍历动规

状态转移方程

逛了下评论区动规的解法,思路都差不多。动规的基本单位是req_skills列表中的技能子集,记录的是每个子集所需的最小人数,子集的个数是2n,n=req_skills.size()。如,req_skills=[A, B, C],动规需要记录的数据的规模是23,下标为 [A]、[B]、[C]、[AB]、[AC]、[BC]、[ABC]。

外层遍历人员列表people,内层遍历已存状态的子集。
例:
第1个人,可更新1种组合的状态,如第1个人的技能为[A],可更新下标为[A]的状态;
第2个人,可更新2种组合的状态,如第2个人的技能为[B],可更新下标为[B]和[BA]的状态;
第3个人,可更新4种组合的状态,如第3个人的技能为[C],可更新下标为[C]、[CA]、[CB]和[CAB]的状态;

状态转移方程:

dp[skillSet + skillSetPerson] = min(dp[skillSet + skillSetPerson], dp[skillSet] + 1)

当前子集的下标为skillSet,对应人数为dp[skillSet],当前person的技能集合为skillSetPerson,如果dp[skillSet+ skillSetPerson]中存的人数 > dp[skillSet] + 1,则更新dp[skillSet+ skillSetPerson] = dp[skillSet] + 1。这里的“+”表示集合的加法。
.

位压缩

因为子集不方便直接作为下标进行索引,所以引入位压缩。那么上例中的下标变成了:0x001、0x010、0x100、0x011、0x101、0x110、0x111,换成十进制可直接用1 ~(23-1)进行索引。
那么需要申请的动规数组即为dp[1<<n],n是需求技能的个数。

状态转移方程更新为:

dp[skillSet | skillSetPerson] = min(dp[skillSet | skillSetPerson], dp[skillSet] + 1)

.

Solution0 遍历动规 + map容器

代码

先是看了讨论区一个点赞比较多的c++解答
思路很清晰,因为最终要返回人员编号的集合,所以动规状态不仅需要知道当前skill子集的最小需求人数,还需要知道是哪些人,所以很自然会想到采用map的结构作为动规状态的记录容器。
PS:python用字典是类似的解法-讨论区python解法

// 解法出自 leetcode1125 讨论区
class Solution0 {
public:
	vector<int> smallestSufficientTeam(vector<string>& req_skills, 
									   vector<vector<string>>& people) {
		int n = req_skills.size();
		
		// 上面提到的动规状态记录dp[1<<n]
		// pair中的first记录skill子集的位表示
		//         second记录的所需要的最优人选编号集合
		map<int, vector<int>> res; 
		res[0] = {};
		
		unordered_map<string, int> skill_map;
		for (int i = 0; i < req_skills.size(); i++)
			skill_map[req_skills[i]] = i;

		// 外层遍历人员列表
		for (int i = 0; i < people.size(); i++)
		{
			int curr_skill = 0;
			for (int j = 0; j < people[i].size(); j++)
				curr_skill |= 1 << skill_map[people[i][j]]; // 将每个人的skill用bit表示

			// 内层遍历已记录的子集
			for (auto it = res.begin(); it != res.end(); it++)
			{
				int comb = it->first | curr_skill; 
				// 状态转移
				// 策略更新条件:
				// 条件1:comb是未记录的新子集
				// 条件2:新策略优于comb的原记录
				if (res.find(comb) == res.end() || res[comb].size() > 1 + res[it->first].size())
				{
					res[comb] = it->second;
					res[comb].push_back(i);
				}
			}
		}
		return res[(1 << n) - 1];
	}
};

分析

缺点就是耗时。耗时248ms,速度排名意外的低。
对比下述的其他动规的写法,Solution1方法耗时的原因主要是有二:

  • map容器操作耗时:map底层是RB-Tree,插入和遍历都很耗时。虽然红黑树的遍历是O(n)的,但红黑树的O(n)遍历开销远大于的vector容器的O(n)遍历开销的。
  • 内层遍历有冗余:内层遍历过程中不断往map中插入新数据,这部分新插入的数据会在本次内层遍历中被重复访问和判断。原因是内层遍历使用的迭代器 map<int, vector< int >> :: iterator并不会记录最初的迭代顺序,map的迭代器统一使用内部红黑树的迭代器,红黑树的iterator++是在树结构中动态查找的。

改进

改进map容器操作耗时,写博客的时候再去看原回答c++解答,作者已经把map改成了unordered_map。
unordered_map应该类似hash_map?所以遍历上比红黑树为内部实现的map快很多。

		int n = req_skills.size();
		unordered_map<int,vector<int>> res;     // using unordered_map, we improve on time
		res.reserve(1 << n);					// using reserved space, we avoid rehash
		//map<int, vector<int>> res;
		res[0] = {};

改进内层遍历冗余:
改进【1】可以去掉冗余的访问和判断。
改进【2】是题目输入的people技能列表会有空集。(啥都不会你来应聘啥,这种输入样例算是嘲讽么233333

			int curr_skill = 0;
			for (int j = 0; j < people[i].size(); j++)
				curr_skill |= 1 << skill_map[people[i][j]]; 
			/* 改进[2] */
			if (curr_skill == 0)
				continue;
			/* 改进[2] */

			for (auto it = res.begin(); it != res.end(); it++)
			{
				/* 改进[1] */
				if (it->first == curr_skill) {
					res[curr_skill] = vector<int>{ i };
					continue;
				}
				if ((it->first&curr_skill) == curr_skill) 
					continue;
				/* 改进[1] */

				int comb = it->first | curr_skill;
				if (res.find(comb) == res.end() || res[comb].size() > 1 + res[it->first].size())
				{
					res[comb] = it->second;
					res[comb].push_back(i);
				}
			}

};

改进前后的性能对比:
改进前后性能对比
.

Solution1 遍历动规 + vector容器

代码

这种解法中把

map<int,vector<int>> dp; 

替换成了

vector<vector<int>> dp(1 << n);

从而节省了map的插入和内存分配时间。
改进后,内层的遍历逻辑也发生了变化:Solution0的内层遍历逻辑是把加入当前person之前得到的技能子集遍历一遍。Solution1的遍历逻辑是,把所有的技能子集遍历一遍。
虽然内层遍历的数量变多了,但vector的访问要远快于map的访问,而且有if判断进行截断,实际上并不会真的遍历所有的子集。

// 解法出自leetcode的Accepted Solutions Runtime Distribution
class Solution1 {
public:
	vector<int> smallestSufficientTeam(vector<string>& req_skills, 
									   vector<vector<string>>& people) {
		unordered_map<string, int> mp;
		int n = req_skills.size();
		for (int i = 0; i < n; i++) {
			mp[req_skills[i]] = (1 << i);
		}
		vector<vector<int>> dp(1 << n);
		for (int id = 0; id < people.size(); id++) {
			int can = 0;
			for (string str : people[id])
				can += mp[str];
			if (can == 0)
				continue;
			for (int i = 0; i < (1 << n); i++) {
			    // 截断措施
				if (i == can) {
					dp[i] = vector<int>{ id };
					continue;
				}
				//if ((i&can) == can) continue;  // 这种截断包含在上一种截断中
				int groupSkill = (i | can);
				if (dp[i].size() > 0 &&
					((dp[groupSkill].size() == 0) ||
					(dp[i].size() + 1 < dp[groupSkill].size() && dp[groupSkill].size() > 0))) {
					dp[groupSkill] = dp[i];
					dp[groupSkill].push_back(id);
				}
			}
		}
		return dp[(1 << n) - 1];
	}
};

分析

用vector就快很多,比unorder_map还快。
不过因为对unorder_map容器不是很了解,不太清楚这个时间改进是容器的操作开销减小带来的,还是内层遍历逻辑变化带来的。
Solution1性能
如果还要继续改进的话,还有一个可以着手的地方:

  • 记录每个技能子集的人员集合需要很多额外的开销,时间上vector的push_back()耗时,空间上要存储2n个vector< int >

改进

见Solution2
.

Solution2 遍历动规 最优解法

代码

基本思路与Solution1一样,区别就是用pre和use两个数组记录了状态转移的路径,在完成所有技能子集的最少人数计算后,用pre和use恢复所需要的person set。

// 解法出自leetcode的Accepted Solutions Runtime Distribution
class Solution2 {
public:
	vector<int> smallestSufficientTeam(vector<string>& req_skills,
		vector<vector<string>>& people) {
		int n = people.size(), m = req_skills.size();
		map<string, int> bitmap;
		for (int i = 0; i < m; i++)
			bitmap[req_skills[i]] = 1 << i;

		vector<int> f(1 << m, n + 2);	// 当前技能组合(ABDEG)所需要的最小人数
		vector<int> pre(1 << m, -1);
		vector<int> use(1 << m, -1);
		f[0] = 0;
		for (int i = 0; i < n; i++){
			int p = 0;
			for (int j = 0; j < people[i].size(); j++)
				p |= bitmap[people[i][j]];
			if (p == 0)
				continue;
				
			for (int j = 0; j < (1 << m); j++){
				// if ((p & j) == p) continue;
				if (p == j) continue; // (p == j)足够了,不需要(p & j) == p
				
				int t = p | j;
				if (f[t] > f[j] + 1){
					f[t] = f[j] + 1;
					pre[t] = j;
					use[t] = i;
				}
			}
		}
		// 生成最终需要的人员集合
		vector<int> ret;
		int now = (1 << m) - 1;
		while (now != 0){
			ret.push_back(use[now]);
			now = pre[now];
		}
		return ret;
	}
};

分析

所需要的几个vector的内存都是统一分配的,主循环中的条件判断也简单了很多。
Solution2性能

Solution DFS

Solution3 DFS 最优解法 全场最优!

代码

最帅气的解法!
其实我觉得这种解法反而是最直观的,最符合人的思维方式。又快又自然,简直完美解法。
当然要写得这么优雅,也是要靠技术啊∠( ᐛ 」∠)_

基本思路:

  • 只要这个人有我需要但我团队里没人会的技能,就把这个人收进来。
  • 如果当前团队满足招聘需求,判断是否需要更新记录的最优招聘策略。然后回退。——对应第一个return
  • 如果当前团队人数已经超过目前记录的最优招聘策略的人数,提前中止这一分支的搜索,然后回退。——对应第二个return
  • 如果所有人的简历已经看完了,还没有招满人,说明当前招聘策略失败,也是回退。——比较隐晦,对应vector< int > sks的遍历结束,相当于函数体最后省略的return
// 解法出自leetcode的Accepted Solutions Runtime Distribution
class Solution3 {
public:
	vector<int> smallestSufficientTeam(vector<string>& req_skills,
		vector<vector<string>>& people) {
		vector<int> res, tm;
		int m = req_skills.size(), n = people.size();
		unordered_map<string, int> ump;
		for (int i = 0; i < m; ++i) ump[req_skills[i]] = i;
		vector<int> sks(n, 0);
		for (int i = 0; i < n; ++i) {
			for (int j = 0; j < people[i].size(); ++j) {
				sks[i] |= 1 << ump[people[i][j]];
			}
		}
		dsf(0, m, sks, tm, res);
		return res;
	}
	
private:
	void dsf(int cur, int &m, vector<int> &sks, vector<int> &tm, vector<int> &res) {
		// 当前挑选的团队已经覆盖了所要求的技能
		// 如果当前挑选的团队比以记录的最优团队人数要少,则更新最优团队
		if (cur == (1 << m) - 1) {
			if (!res.size() || res.size() > tm.size()) res = tm;
			return;
		}

		// 虽然当前团队还没有覆盖所要求的技能
		// 但当前挑选的团队人数已经大于目前记录的符合要求的最优团队人数要多
		// 所以放弃当前团队挑人的策略
		if (res.size() && res.size() <= tm.size()) return;

		// 找到一个当前团队(tm)不具备的技能(req_skills的第bitct个技能)
		int bitct = 0;
		while (((cur >> bitct) & 1) == 1) ++bitct;

		// 在people中找会技能bitct的人,每个候选人生成一个策略分支
		for (int i = 0; i < sks.size(); ++i) {
			if (((sks[i] >> bitct) & 1) == 1) {
				tm.push_back(i);
				dsf(cur | sks[i], m, sks, tm, res);
				tm.pop_back(); // 回退
			}
		}
	}
};

分析

之前跑出来是8ms,faster than 100.0% c++ online submissions。 应该就是最优的解法了。
Solution3性能

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值