一、最优除法(中等)
考虑数组中每个元素大小都>0,且对于最普通,不添加括号的情况,第0个数一定是分子,而第1个数一定是分母,但后面的数不一定,那我们只需要让分子足够大即可,那就是让第1个数后面的数全部成为分子。
class Solution {
public String optimalDivision(int[] nums) {
StringBuilder sb = new StringBuilder();
// 遍历数组中元素
for (int i = 0; i < nums.length; i++) {
sb.append(nums[i]);
// 后面紧跟除号,最后一个元素除外
if (i + 1 < nums.length) {
sb.append('/');
}
}
if (nums.length > 2) {
sb.insert(sb.indexOf("/") + 1, '(');
sb.append(')');
}
return sb.toString();
}
}
二、K站中转内最便宜的航班(中等)
看似最短路径问题,需要在SPFA模板上做些改动,首先是中转站k站的限制,给出下面的示例:
k限制为1,刚开始0站点入队,第一次BFS遍历,1、2站点入队, k–,k=0,还可以继续入队,此时由于之前站点2出队了,而站点1又扩散到站点2,由于SPFA会对起点0到站点2的最短路径更新为2,此时站点2又扩散到终点3,此时得到的0 - 3的最短路径=3,但实际上=3的这条路径根本无法到达站点3(因为k=1),答案应该是6。
避免上述问题很简单,我们不使用vis数组,我们用队列记录每一条路径的信息:终点、花费和中转站次数。 这样从0-1-2的路径产生的起点到站点2的最短路径,不会影响从0-2的路径产生的最短路径,因为它们都是用node节点单独存储路径长度的,不是只存储最短路径。
不使用vis数组是为了能够实现路径的多次记录(因为可能单个结点多次出现在不同路径),之前SPFA使用vis是避免重复找最短路径,但本题不是找普通的最短路径,是在有限制情况下的最短路径。
class node {
int curId;
int cost;
int step;
node(int curId, int cost, int step) {
this.step = step;
this.cost = cost;
this.curId = curId;
}
}
class Solution {
public int findCheapestPrice(int n, int[][] flights, int src, int dst, int k) {
// 建图
LinkedList<int[]>[] graph = new LinkedList[n];
for (int i = 0; i < n; i++) {
graph[i] = new LinkedList<>();
}
for (int[] edge : flights) {
graph[edge[0]].add(new int[]{edge[1], edge[2]});
}
// 记录起点到任意点的最短路径
int[] distTo = new int[n];
Arrays.fill(distTo, Integer.MAX_VALUE);
distTo[src] = 0;
// 不用vis数组,为什么?
Queue<node> queue = new LinkedList<>();
// 起点入队
queue.offer(new node(src, 0, 0));
// 关键在于中转次数 k 的处理
while (!queue.isEmpty()) {
// 出队
node tmp = queue.poll();
// 说明当前遍历的点都是超过中转次数的(BFS的特性)
if (tmp.step > k) {
break;
}
// 遍历相邻边
for (int[] edge : graph[tmp.curId]) {
int to = edge[0];
int weight = edge[1];
// 注意是当前路径的花费cost用于判断更新最短路径,而不是比较之前的最短路径
if (distTo[to] > tmp.cost + weight) {
distTo[to] = tmp.cost + weight;
queue.offer(new node(to, distTo[to], tmp.step + 1));
}
}
}
return distTo[dst] == Integer.MAX_VALUE ? -1 : distTo[dst];
}
}
三、最多可达成的换楼请求数目(困难)
首先想法是暴力,遍历每个request,用一个数组builds记录进出人数情况,进+1,出-1,再最后统计builds=0的情况,问题在于无法统计方案数!!一条条request是一个个方案,但是整个累加求和的过程忽略了request的作用。
考虑到请求数目较小,可以使用状态压缩,用二进制串中的1来表示选择requests中的哪些request,0则表示不选取。这个二进制串的长度就是requests数组的长度。
这类用二进制串表示方案选取的题目,称为状态压缩。
class Solution {
// 方便全局使用
int[][] res;
public int maximumRequests(int n, int[][] requests) {
res = requests;
int ans = 0;
// 请求条数
int m = requests.length;
// 遍历全不选 - 全选的方案
// 最多的情况就是:m个方案全是1:111...
for (int i = 0; i < (1 << m); i++) {
int cnt = getCnt(i);
if (cnt <= ans) continue;
if (check(i, m)) ans = cnt;
}
return ans;
}
// check当前方案是否可行
public boolean check(int x, int m) {
int[] cnt = new int[20];
int sum = 0;
// 有m - 1位
for (int i = 0; i < m; i++) {
// 判断具体某一位=1(选取了某一位)
if (((x >> i) & 1) == 1) {
int from = res[i][0];
int to = res[i][1];
if (++cnt[from] == 1) sum++;
if (--cnt[to] == 0) sum--;
}
}
return sum == 0;
}
// 获取当前方案数的请求数目
public int getCnt (int x) {
// 统计当前二进制串中1的个数
int cnt = 0;
while (x != 0) {
cnt++;
// 每次将x的最后一位置0
x &= x - 1;
}
return cnt;
}
}
代码中用到的二进制操作一定要熟悉。
下面继续就状态压缩问题做相应练习。
四、括号生成(中等)
能使用状态压缩的题目,需要n较小,因为状态压缩实际上是通过遍历二进程串实现暴力穷举。
class Solution {
public List<String> generateParenthesis(int n) {
List<String> ans = new LinkedList<>();
for (int i = 0; i < (1 << n * 2); i++) {
StringBuilder sb = new StringBuilder();
int count = 0;
// 遍历当前方案
for (int j = 0; j < n * 2; j++) {
if (((i >> j) & 1) == 1) {
// 为1的地方,添加左括号
sb.append('(');
count++;
} else {
// 为0的地方,添加右括号
sb.append(')');
count--;
if (count < 0) {
break;
}
}
}
if (count == 0) {
ans.add(sb.toString());
}
}
return ans;
}
}
如果题目的n数额过大,如何处理?显然,得用搜索DFS、BFS:
class Solution {
public List<String> ans = new LinkedList<>();
public List<String> generateParenthesis(int n) {
dfs(n, 0, 0, "");
return ans;
}
public void dfs(int n, int left, int right, String str) {
if (left == n && right == n) {
ans.add(str);
return;
}
// 右括号数 > 左括号数,剪枝
if (left < right) {
return;
}
if (left < n) {
dfs(n, left + 1, right, str + '(');
}
if (right < n) {
dfs(n, left, right + 1, str + ')');
}
}
}
注意,不要用StringBuilder,因为在函数传递过程中,会共享同一个StringBuilder。
五、完成所有工作的最短时间(困难)
能不能用状态压缩,关键看数据量!
通常与状态压缩配合使用的,还有DP,这类算法统称:状态压缩DP,利用状态压缩 + DP的特性,实现问题的求解。
图源:https://leetcode-cn.com/problems/find-minimum-time-to-finish-all-jobs/solution/zhuang-ya-dp-jing-dian-tao-lu-xin-shou-j-3w7r/
class Solution {
public int minimumTimeRequired(int[] jobs, int k) {
int n = jobs.length;
int[] tot = new int[1 << n];
// 计算每个子集的工作总时间
for (int i = 1; i < (1 << n); i++) {
for (int j = 0; j < n; j++) {
if ((i & (1 << j)) == 0) continue;
// left: 代表子集i去除了元素j后剩下的部分总和
// 可以利用这部分总和计算出新的子集 i 的工作总时间
int left = (i - (1 << j));
tot[i] = tot[left] + jobs[j];
// 只需要找到一个子集即可
break;
}
}
// dp[i][j]: 前 i 个工人为了完成作业子集 j,需要花费的最大工作时间的最小值
int[][]dp = new int[k][1 << n];
// 注意一项工作只能分配给一个工人,一个工人可以分配多个工作
for (int i = 0; i < (1 << n); i++) {
// 只有一个工人时,每项工作只能一项项完成
dp[0][i] = tot[i];
}
// 遍历工人数
for (int i = 1; i < k; i++) {
// 遍历工作集合,最后答案应该是所有jobs都为1,代表每个工作都被选取了
for (int j = 0; j < (1 << n); j++) {
int min = Integer.MAX_VALUE;
int s = j;
// 找j的子集s,让第i个工人去做,剩下的i-1个工人去完成子集j-s
while (s != 0) {
// 子集j-s部分
int left = j - s;
// 保证题目中的最大花费时间,一部分时间是一个工人单独完成子集s所需时间
// 另一部分时间是剩余工人,完成j-s剩余子集所需时间
int hour = Math.max(dp[i - 1][left], tot[s]);
// 找最小值
min = Math.min(min, hour);
// 找当前j的子集
s = (s - 1) & j;
}
dp[i][j] = min;
}
}
return dp[k - 1][(1 << n) - 1];
}
}
状态压缩DP题目,一般来说都是困难级别的,它不仅需要DP,还需要使用与或非等操作,实现对二进串的使用。