1. 题目
1.1 题意
严格按照选课先修顺序选课,每个学期选课数有上限,求选完所有课程的最短学期数
1.2 分析
这道题的数据量很小,而且作为困难题,开始考虑是不是用暴力递归。
1 <= n <= 15
事实上,我在写的时候大意了没有闪,把他看成一个拓扑排序,觉得依次把入度为0的课程选择即可。
第60个测试样例直接错。意识到每个学期选择的入度为0的课程是有影响的。那么递归直接忽略,先考虑可否贪心。
能不能每次贪心选择入度为0,但出度尽可能大的节点呢?这样可以使得剩下的图的边数减少的更多。
思考一下,设想一个极限场景:有一条关键路径上有很多课程,但是都只受限于一个出度为1,入度为0的课程,这样这条路径会被“饿死”,被选择到的时间很晚,这并不是最优的方法。(下图举例,下图是可以在5个学期被选完的,关键取决于第一个学期的选择)
排除了贪心就考虑动态规划,
第一个问题,怎么存信息?最多只有15个课程,可以使用状态压缩的方法,利用n位one-hot编码(int变量即可)来表示每个课程是否已经被修过。
第二个问题,怎么递推?由状态信息(存的是该情况下已经修过的课程和没有修过的课程),查找此情况下入度为0的节点,如果节点数小于等于k则选上所有课程,节点数大于k需要做一个 C N k C_{N}^{k} CNk 从入度为0的节点中选取k个节点,并且把所有的情况都遍历一遍,这里可以使用dfs去做。为了防止出现重复状态可以使用set做容器
1.3 我的解法
class Solution {
public:
void dfs(int vis, int ind, int k, int chooseNum, vector<int> &node,set<int> &st){
// 细节第一个递归边界在第二个递归边界前
// 不然会缺少情况
if(chooseNum == k){
// 选到了足够的课程
st.insert(vis);
return;
}
// 所有情况都已经遍历
if(ind >= node.size()){
return;
}
// not choose
// 不选择课程
dfs(vis, ind+1, k, chooseNum, node, st);
// choose
// 选择该课程
// 这里注意位运算优先级顺序 都打上括号
if( ( vis | (1<<node[ind]) ) != vis)
// 简单的剪枝
// 即该课程未被选过
dfs(vis|(1<<node[ind]), ind+1, k, chooseNum+1, node, st);
}
int minNumberOfSemesters(int n, vector<vector<int>>& relations, int k) {
// 状态压缩
// 这里实际可以使用两个set
vector<set<int> > state;
// 初始化
state.emplace_back(set<int>{0} );
int res = 0;
int m = relations.size();
while(1){
res++; // 统计学期数
set<int> st;
for(auto it = state.back().begin(); it != state.back().end(); it++){
// 每一次要把前一个学期所有的情况都做一遍计算
int vis = *it;
// get in degree
// 计算入度
vector<int> inDegree(n+1, 0);
for(int i=0; i<m; i++){
// not vis
// 位运算细节,表示没有修该课程
if((vis & (1 << relations[i][0]) ) == 0 ){
inDegree[relations[i][1]]++;
}
}
// 查找入度为0 并且没有访问的节点
// find node: inDegree = 0
vector<int> node;
for(int i=1;i<=n;i++){
if(inDegree[i] == 0 && ( ( vis & (1<<i) ) == 0) ){
// not vis
node.emplace_back(i);
}
}
// 如果可选课程不足k,全选
if(node.size()<=k){
for(int i:node){
vis |= (1<<i);
}
st.insert(vis);
}else{
// choose node
// combine and arrange all cases
// 可选课程较多,做排列组合,遍历所有情况
dfs(vis, 0, k, 0, node, st);
}
}
if(st.find( (1<<(n+1)) - 2 ) != st.end()){
// 判断有没有全部修读的情况
// 有可以直接结束,(这个感觉有点像bfs
// (1<<(n+1)) - 2 注意运算优先级
// 我使用的是从第1位到第n位来表示是否访问过该节点
// 所有节点都访问的情况应该是 第1~n位为1 其余位为0
// (1<<(n+1)) - 1 是第0~n位全为1
// 再减1 第0位也是0了
break;
}
else{
state.emplace_back(st);
}
}
return res;
}
};
1.4 学习题解反思
时间复杂度分析:
最外层循环最多n次(一学期修一门课程)
set一层的循环,每次的状态最多是
C
n
k
C_{n}^{k}
Cnk(所有课程都可以直接选)
内层找入度、节点的,最多次是边的数目: n * (n-1) / 2
然后node中做dfs的部分上界为2^n(两种情况,深度为node的大小)
最后乘再一起
n
3
∗
2
n
∗
C
n
k
n^3*2^n*C_{n}^{k}
n3∗2n∗Cnk 这其实是个不确切的上界
空间复杂度分析:
最坏情况下n次循环,然后每次最多装
C
n
k
C_{n}^{k}
Cnk个状态
(其实这里可以把前面的n去掉,因为每次都之和前一个学期状态有关,用两个set循环替也是可以)
题解学习:g 没学
题解的方法很强,又是学到了的一天。
题解把所有状态都枚举,然后把每个状态对应需要学习的先修课提前计算好(这是题解的时间复杂度低于我的时间复杂度的原因,不用每次去求状态对应要学的先修课程,但是是牺牲空间的前提,不过我的解法也可以提前将每个状态可以学的课程,是用空间换时间的代价)
另外他在可修课程数大于k需要做一个
C
N
k
C_{N}^{k}
CNk 的情况使用了一个二进制子集枚举的方法。
1.5 bug日记
1.5.1 思路想错
1.5.2 位运算优先级
2. 后记
每日困难题坐牢 人菜瘾大多练
仅分享自己的想法,有意见和指点非常感谢