力扣 223 场 周赛
第三题:
https://leetcode-cn.com/problems/minimize-hamming-distance-after-swap-operations/
(相似题目:1202. 交换字符串中的元素 https://leetcode-cn.com/problems/smallest-string-with-swaps/)
第四题:
1723. 完成所有工作的最短时间 https://leetcode-cn.com/problems/smallest-string-with-swaps/
感悟与反思:
首先两题刚拿到的时候思路都是正确的——(3) 的寻找连通区域和遍历 和(4) 的二分答案范围来求解,总体难度感觉不高,但是还是没有做出来。
对于(3): 对于并查集中的划分连通区域不熟悉(利用map来处理) (4) 对于状态压缩不熟悉(利用二进制来遍历求解背包问题)
(1) 并查集的连通区域遍历
刚开始想复杂了,想着用map来存储每一个结点为头的连通区域,这样会使合并两个结点的时候过于复杂,而可以就普通合并之后,再遍历一次
两种方法,但是第二种优于第一种(时间空间上都是)
并查集的合并连通区域的方法,设置一个map来记录每一个节点所带的子节点
map<int,vector<int>> mp;
// mp[i] = {....} i 结点的子节点
void union_ab(int a,int b) {
int x = min(parent[a],parent[b]);
if(parent[a] != x) {
for(auto& i : mp[parent[a]]) {
parent[i] = x;
mp[x].push_back(i);
}
mp[a].clear();
}
else {
for(auto& i : mp[parent[b]]) {
parent[i] = x;
mp[x].push_back(i);
}
mp[b].clear();
}
}
// 使用rank可以优化查询的时间复杂度
void union_ab(int a,int b) {
while(a != parent[a]) a = parent[a];
while(b != parent[b]) b = parent[b];
if(a == b) return ;
if(rank[a] > rank[b])
parent[b] = a;
else {
parent[a] = b;
if(rank[a] == rank[b]) rank[b]++;
}
}
map[findFa(a)].push_back(a); // 在a的连通区域中增加结点
然后再遍历一遍
parent[i] == i
// 表示 结点 i 为某一个连通区域的头结点,遍历改区域即可
(4) 状态DP
参考题解文章:
【状压DP】新手教学,抛弃繁琐的 dfs 剪枝优化 https://leetcode-cn.com/problems/find-minimum-time-to-finish-all-jobs/solution/zhuang-ya-dp-jing-dian-tao-lu-xin-shou-j-3w7r/
动态规划——状态压缩DP https://zhuanlan.zhihu.com/p/131585177
状态压缩的主要的思想就是有一个二进制串来表示当前的状态
比如 有5个背包(a0,a1,a2,a3,a4)来表示是否为空,则 6(00110)就表示a2,a3的背包不为空而其他背包为空
总结一下,我们用二进制的0和1表示一个二元集合的状态。
可以简单认为某个物品存在或者不存在的状态。
由于二进制的0和1可以转化成一个int整数,也就是说我们用整数代表了一个集合的状态。
这样一来,我们可以用整数的加减计算来代表集合状态的变化。
这是一个NP-hard问题。(具体简绍在下面)
对于一个二进制集合的子集的遍历方法:
for (int sub = S; sub; sub = (sub - 1) & S) {
// sub 为 S 的子集
}
基于二分和状态压缩的代码如下:
class Solution {
public:
int minimumTimeRequired(vector<int>& jobs, int k) {
int n = jobs.size();
vector<int> tot(1 << n, 0);
// tot[i] -> 表示jobs的子集 i 的工作时间总和
/*
对于 tot 的预处理,并没有直接对于每一个i去求解
而且通过去遍历12位找到已经求解了的来计算,优化了时间复杂度
*/
for (int i = 1; i < (1 << n); i++) {
for (int j = 0; j < n; j++) {
if ((i & (1 << j)) == 0) continue;
int left = (i - (1 << j));
tot[i] = tot[left] + jobs[j];
break;
}
}
int l = *max_element(jobs.begin(), jobs.end());
int r = accumulate(jobs.begin(), jobs.end(), 0);
// 确定上下边界
while (r >= l) {
int mid = (l + r) / 2;
vector<int> dp(1 << n, INT_MAX / 2);
// dp[i] -> 工作量为 i 时 且每个工人的工作时间限制在 mid 的情况下
// 需要的工人的最小数量
dp[0] = 0;
for (int i = 0; i < (1 << n); i++) {
for (int s = i; s; s = (s - 1) & i) {
// 对于 集合 i 的子集遍历
// 即对 i-s 的大小由 之前的最小工人数量解决,s 为一个新的工人工作解决
if (tot[s] <= mid) {
dp[i] = min(dp[i], dp[i-s] + 1);
}
}
}
// 当最小工作工人的数量小于已有的工人时 更新r
if (dp[(1<<n) - 1] <= k) {
r = mid-1;
} else { // 否则更新l
l = mid + 1;
}
}
return r+1;
}
};
直接状态压缩加DP:
class Solution {
public:
int minimumTimeRequired(vector<int>& jobs, int k) {
int n = jobs.size();
vector<int> tot(1 << n, 0);
for (int i = 1; i < (1 << n); i++) {
for (int j = 0; j < n; j++) {
if ((i & (1 << j)) == 0) continue;
int left = (i - (1 << j));
tot[i] = tot[left] + jobs[j];
break;
}
}
vector<vector<int>> dp(k+1, vector<int>(1 << n, -1));
// dp[i][j] -> 前 i 个工人为了完成作业子集 j,需要花费的最大工作时间的最小值。
/* 转移方程 : dp[i][j] = 遍历 s为j的子集 min(
max(dp[i-1][j-s],tot[s]);
)
*/
int res = 0;
for (int i = 0; i < (1 << n); i++) {
dp[1][i] = tot[i];
res = max(res,tot[i]);
}
// 即只有一个人的时候每个人需要完成的最小时间就是该工作集的总和
for (int j = 2; j <= k; j++) {
// 逐渐加入工作的人数
for (int i = 0; i < (1 << n); i++) { // 得到dp[j][.] . 的所有可能情况
int minv = INT_MAX;
for (int s = i; s; s = (s - 1) & i) { // 枚举 i 的全部子集
int left = i - s;// 去掉的子集
int val = max(dp[j-1][left], tot[s]); // 求需要的最小时间
minv = min(minv, val);
}
dp[j][i] = minv;
}
res = min(res,dp[j][(1<<n)-1]);
}
return res;
}
};
深搜枚举:
因为这题的数据集 n == 12 ,可以通过深搜 + 剪枝 来解决
注意剪枝的技巧 从小到大枚举工人的数量
加入测试变量 test 来标记搜索运行的次数 可以看到直接枚举的话数量集是很高的,而从小到大枚举会缩小时间复杂度
并且还可以进行优化,在将jobs[a] 放入集合时,优先放入工作时间少的,这样时间复杂度会进一步提高
class Solution {
public:
vector<int> jobs;
vector<int> s;
// 每个工人所分配到的工作时间
int res = 1e9;
void dfs(int a,int b,int c) {
// c 存的是工人集合中分配到的工作集的最大值
// a 是当前的jobs的第几个工作
// b 集合的个数
if(c > res) return ;
// 技巧 1 : 当最大值大于 已存在的一个潜在答案时,该条分支杀死
if(a == jobs.size()) {
res = c;
return ;
}
// 深搜 将工作a枚举加入每一个工人的集合
for(int i = 0;i<b;i++) {
s[i]+=jobs[a];
dfs(a+1,b,max(c,s[i]));
s[i]-=jobs[a];
}
// 技巧2 : 将工人的集合大小从小到大进行
// 这样在技巧 1 出可以杀死的分支会变多,优化时间复杂度
// 当不这样处理的时候也会超时
if(b < s.size()) {
s[b] += jobs[a];
dfs(a+1,b+1,max(c,s[b]));
s[b] -= jobs[a];
}
return ;
}
int minimumTimeRequired(vector<int>& _jobs, int k) {
jobs = _jobs;
s.resize(k,0);
dfs(0,0,0);
return res;
}
};
拓展 NP 问题的了解
P问题可以认为是已经解决的问题,这个解决的定义是可以做多项式的时间复杂度内解决。
所谓的多项式,也就是O(n^k),这里的k是一个常数。
与多项式相反的函数有很多,比如指数函数、阶乘等等。
NP问题并不是P问题的反义,这里的N不能理解成No,就好像noSQL不是非SQL的意思一样。NP问题指的是可以在多项式内验证解的问题。
比如给定一个排序的序列让我们判断它是不是有序的,这很简单,我们只需要遍历一下就好了。再比如大整数的因式分解,我们来做因式分解会很难,但是让我们判断一个因式分解的解法是不是正确则要简单得多,我们直接把它们乘起来和原式比较就可以了。
显然所有P问题都是NP问题,既然我们可以多项式内找到解,那么必然我们也可以在多项式内验证解是否正确。但是反过来是否成立呢,是否多项式时间内可以验证解的问题,也可以通过某种算法可以在多项式时间内被解开呢?究竟是我们暂时还没有想到算法,还是解法一开始就不存在呢?
为了证明这个问题,科学家们又想出了一个办法,就是给问题做规约。举个例子,比如解方程,我们解一元一次方程非常简单,而解二元一次方程则要困难一些。如果我们想出了解二元一次方程的办法,那么必然也可以用来解一元一次方程,因为我们只需要令另一个未知数等于0就是一元一次方程了。
同理,我们也可以把NP问题做转化,将它的难度增大,增大到极限成为一个终极问题。由于这个终极问题是所有NP问题转化得到的,只要我们想出算法来解决了终极问题,那么,所有的NP问题全部都迎刃而解。就比如如果我们想出了解N元方程的算法,那么这一类解方程的问题就都搞定了。这种转化之后得到的问题称为NP完全问题,也叫做NPC问题。
最后,还有一个NP-Hard问题,NP-Hard问题是说所有NP问题可以经过转化得到它,但是它本身并不是NP问题,也就是说我们无法在多项式时间内判断它的解是否正确。