脑筋急转弯
算法面试中,有一类题是实现要求不高,但是思维难度很大的类型,这便是脑筋急转弯。
今天通过 道题目一起学习此类算法。
406. 根据身高重建队列
假设有打乱顺序的一群人站成一个队列,数组 people 表示队列中一些人的属性(不一定按顺序)。每个 表示第 个人的身高为 ,前面 正好 有 个身高大于或等于 的人。
请你重新构造并返回输入数组 people 所表示的队列。返回的队列应该格式化为数组 queue ,其中 是队列中第 个人的属性( 是排在队列前面的人)。
示例 1:
输入:people = [[7,0],[4,4],[7,1],[5,0],[6,1],[5,2]]
输出:[[5,0],[7,0],[5,2],[6,1],[4,4],[7,1]]
解释:
编号为 0 的人身高为 5 ,没有身高更高或者相同的人排在他前面。
编号为 1 的人身高为 7 ,没有身高更高或者相同的人排在他前面。
编号为 2 的人身高为 5 ,有 2 个身高更高或者相同的人排在他前面,即编号为 0 和 1 的人。
编号为 3 的人身高为 6 ,有 1 个身高更高或者相同的人排在他前面,即编号为 1 的人。
编号为 4 的人身高为 4 ,有 4 个身高更高或者相同的人排在他前面,即编号为 0、1、2、3 的人。
编号为 5 的人身高为 7 ,有 1 个身高更高或者相同的人排在他前面,即编号为 1 的人。
因此 [[5,0],[7,0],[5,2],[6,1],[4,4],[7,1]] 是重新构造后的队列。
示例 2:
输入:people = [[6,0],[5,0],[4,0],[3,2],[2,2],[1,4]]
输出:[[4,0],[5,0],[2,2],[3,2],[1,4],[6,0]]
提示:
- 题目数据确保队列可以被重建
构造 + 二分 + 树状数组
这是一道非常综合的题目。
首先根据双关键字排序:当「高度(第一维)」不同,根据高度排升序,对于高度相同的情况,则根据「编号(第二维)」排降序。
采取这样的排序规则的好处在于:在从前往后处理某个 时,我们可以直接将其放置在「当前空位序列(从左往后统计的,不算已被放置的位置)」中的 位(预留了前面的 个位置给后面的数)。
关于「空位序列」如图所示(黄色代表已被占用,白色代表尚未占用):
具体的,我们按照构造的合理性来解释双关键字排序的合理性,假设当前处理的是 :
根据「高度」排升序,根据「编号」排降序:由于首先是根据「高度」排升序,因此当 被放置在「当前空位序列」的第 之后,无论后面的 如何放置,都不会影响 的合法性:后面的数的高度都不低于 ,无论放在 前面还是后面都不会影响 的合法性。
同时对于高度(第一维)相同,编号(第二维)不同的情况,我们进行了「降序」处理,因此「每次将 放置在空白序列的 位置的」的逻辑能够沿用:
对于「高度」相同「编号」不同的情况,会被按照「从右到左」依次放置,导致了每个 被放置时,都不会受到「高度」相同的其他 所影响。换句话说,当 放置时,其左边必然不存在其他高度为 的成员。
剩下的在于,如何快速找到「空白序列中的第 个位置」,这可以通过「二分 + 树状数组」来做:
对于已被使用的位置标记为 ,未使用的位置为 ,那么第一个满足「 的个数大于等于 」的位置即是目标位置,在长度明确的情况下,求 的个数和求 的个数等同,对于位置 而言(下标从 开始,总个数为 ),如果在 范围内有 个 ,等价于有 个 。
求解 范围内 的个数等价于求前缀和,即「区间查询」,同时我们每次使用一个新的位置后 ,需要对其进行标记,涉及「单点修改」,因此使用「树状数组」进行求解。
代码:
class Solution {
int n;
int[] tr;
int lowbit(int x) {
return x & -x;
}
void add(int x, int v) {
for (int i = x; i <= n; i += lowbit(i)) tr[i] += v;
}
int query(int x) {
int ans = 0;
for (int i = x; i > 0; i -= lowbit(i)) ans += tr[i];
return ans;
}
public int[][] reconstructQueue(int[][] ps) {
Arrays.sort(ps, (a, b)->{
if (a[0] != b[0]) return a[0] - b[0];
return b[1] - a[1];
});
n = ps.length;
tr = new int[n + 1];
int[][] ans = new int[n][2];
for (int[] p : ps) {
int h = p[0], k = p[1];
int l = 1, r = n;
while (l < r) {
int mid = l + r >> 1;
if (mid - query(mid) >= k + 1) r = mid;
else l = mid + 1;
}
ans[r - 1] = p;
add(r, 1);
}
return ans;
}
}
- 时间复杂度:排序的复杂度为 ;共要处理 个 ,每次处理需要二分,复杂度为 ;每次二分和找到答案后需要操作树状数组,复杂度为 。整体复杂度为
- 空间复杂度:
423. 从英文中重建数字
给你一个字符串 s ,其中包含字母顺序打乱的用英文单词表示的若干数字(0-9)。按 升序 返回原始的数字。
示例 1:
输入:s = "owoztneoer"
输出:"012"
提示:
s[i]为["e","g","f","i","h","o","n","s","r","u","t","w","v","x","z"]这些字符之一s保证是一个符合题目要求的字符串
模拟
题目要求我们将打乱的英文单词重建为数字。
我们可以先对 s 进行词频统计,然后根据「英文单词中的字符唯一性」确定构建的顺序,最后再对答案进行排序即可。
具体的,zero 中的 z 在其余所有单词中都没出现过,我们可以先统计 zero 的出现次数,并构建 ;然后观察剩余数字,其中 eight 中的 g 具有唯一性,构建 ;再发现 six 中的 x 具有唯一性,构建 ;发现 three 中的 h 具有唯一性(利用在此之前 eight 已经被删除干净,词频中仅存在 three 对应的 h),构建 ...
最终可以确定一个可行的构建序列为 0, 8, 6, 3, 2, 7, 5, 9, 4, 1。
代码:
class Solution {
static String[] ss = new String[]{"zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine"};
static int[] priority = new int[]{0, 8, 6, 3, 2, 7, 5, 9, 4, 1};
public String originalDigits(String s) {
int n = s.length();
int[] cnts = new int[26];
for (int i = 0; i < n; i++) cnts[s.charAt(i) - 'a']++;
StringBuilder sb = new StringBuilder();
for (int i : priority) {
int k = Integer.MAX_VALUE;
for (char c : ss[i].toCharArray()) k = Math.min(k, cnts[c - 'a']);
for (char c : ss[i].toCharArray()) cnts[c - 'a'] -= k;
while (k-- > 0) sb.append(i);
}
char[] cs = sb.toString().toCharArray();
Arrays.sort(cs);
return String.valueOf(cs);
}
}
- 时间复杂度:令 为最终答案的长度, 为所有英文单词的字符总长度。构建答案的复杂度为 ;对构建答案进行排序复杂度为 。整体复杂度为
- 空间复杂度:
419. 甲板上的战舰
给你一个大小为 的矩阵 表示甲板,其中,每个单元格可以是一艘战舰 'X' 或者是一个空位 '.' ,返回在甲板 上放置的 战舰 的数量。
战舰 只能水平或者垂直放置在 上。换句话说,战舰只能按 ( 行, 列)或 ( 行, 列)的形状建造,其中 可以是任意大小。
两艘战舰之间至少有一个水平或垂直的空位分隔 (即没有相邻的战舰)。
示例 1:
输入:board = [["X",".",".","X"],[".",".",".","X"],[".",".",".","X"]]
输出:2
示例 2:
输入:board = [["."]]
输出:0
提示:
- 是
'.'或'X'
进阶:你可以实现一次扫描算法,并只使用 额外空间,并且不修改 board 的值来解决这个问题吗?
脑筋急转弯
如果「允许扫描多次」或者「使用与输入同规模的空间」的话,做法都十分简单:
- 允许扫描多次,但空间只能 :每次遇到
X的格子,则将X所在的战舰修改为-,统计完答案后,再扫描一次,将-恢复为X即可; - 扫描一次,但空间允许 :使用一个与矩阵同等大小的辅助数组 记录访问过的位置即可。
但题目要求「扫描一次」并且「空间 」,这就需要有点「脑筋急转弯」了。
注意这里的「扫描一次」是指使用一次遍历,而非要求每个单元格仅能访问一次,注意两者区别。
思考上述两种做法,我们本质 都是在战舰的首个格子进行计数,并将该战舰的所有格子进行处理,同时使用去重手段(原数组标记 或 使用辅助数组)来防止该战舰在后面遍历中被重复计数。
如果我们能够找到某种规律,直接判断出某个 X 格子是否为战舰开头,则不再需要其他去重手段。
当且仅当某个 X 格子的「上方」&「左方」不为 X 时,该格子为战舰首个格子,可以进行计数,同时需要注意当前当为 (没有「上方」)和当前列为 (没有「左方」)时的边界情况。
- 一次扫描 + 代码:
class Solution {
public int countBattleships(char[][] board) {
int m = board.length, n = board[0].length;
int ans = 0;
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
if (i > 0 && board[i - 1][j] == 'X') continue;
if (j > 0 && board[i][j - 1] == 'X') continue;
if (board[i][j] == 'X') ans++;
}
}
return ans;
}
}
908. 最小差值 I
给你一个整数数组 nums,和一个整数 k 。
在一个操作中,您可以选择 的任何索引 i 。将 改为 ,其中 是一个范围为 的整数。对于每个索引 i ,最多 只能 应用 一次 此操作。
nums 的 分数 是 nums 中最大和最小元素的差值。
在对 nums 中的每个索引最多应用一次上述操作后,返回 nums 的最低 分数 。
示例 1:
输入:nums = [1], k = 0
输出:0
解释:分数是 max(nums) - min(nums) = 1 - 1 = 0。
提示:
脑筋急转弯
根据题意,对于任意一个数 而言,其所能变化的范围为 ,我们需要最小化变化后的差值。而当 足够大时,我们必然能够将所有数变为同一个值,此时答案为 ,而更一般的情况,我们能够缩减的数值距离为 ,因此如果原来的最大差值为 ,若 时,答案为 ,否则答案为 。
代码:
class Solution {
public int smallestRangeI(int[] nums, int k) {
int max = nums[0], min = nums[0];
for (int i : nums) {
max = Math.max(max, i);
min = Math.min(min, i);
}
return Math.max(0, max - min - 2 * k);
}
}
- 时间复杂度:
- 空间复杂度:
2038. 如果相邻两个颜色均相同则删除当前颜色
总共有 个颜色片段排成一列,每个颜色片段要么是 'A' 要么是 'B' 。
给你一个长度为 的字符串 colors ,其中 表示第 个颜色片段的颜色。
Alice 和 Bob 在玩一个游戏,他们轮流从这个字符串中删除颜色。Alice 先手 。
- 如果一个颜色片段为
'A'且相邻两个颜色都是颜色'A',那么 Alice 可以删除该颜色片段。Alice 不可以删除任何颜色'B'片段。 - 如果一个颜色片段为
'B'且相邻两个颜色都是颜色'B',那么 Bob 可以删除该颜色片段。Bob 不可以删除任何颜色'A'片段。 - Alice 和 Bob 不能从字符串两端删除颜色片段。
- 如果其中一人无法继续操作,则该玩家输掉游戏且另一玩家获胜。
假设 Alice 和 Bob 都采用最优策略,如果 Alice 获胜,请返回 true,否则 Bob 获胜,返回 false。
示例 1:
输入:colors = "AAABABB"
输出:true
解释:
AAABABB -> AABABB
Alice 先操作。
她删除从左数第二个 'A' ,这也是唯一一个相邻颜色片段都是 'A' 的 'A' 。
现在轮到 Bob 操作。
Bob 无法执行任何操作,因为没有相邻位置都是 'B' 的颜色片段 'B' 。
因此,Alice 获胜,返回 true 。
提示:
colors只包含字母'A'和'B'
脑筋急转弯
根据删除规则,删除任意一个 A 不会影响可被删删除的 B 的数量,反之亦然。
因此直接统计「可删除的 A 的数量」和「可删除的 B 的数量」,分别记为 和 ,比较 和 的大小即可得到答案(只有 时,先手获胜)。
代码:
class Solution {
public boolean winnerOfGame(String colors) {
char[] cs = colors.toCharArray();
int n = cs.length;
int a = 0, b = 0;
for (int i = 1; i < n - 1; i++) {
if (cs[i] == 'A' && cs[i - 1] == 'A' && cs[i + 1] == 'A') a++;
if (cs[i] == 'B' && cs[i - 1] == 'B' && cs[i + 1] == 'B') b++;
}
return a > b;
}
}
- 时间复杂度:
- 空间复杂度:使用
toCharArray操作会产生新数组,复杂度为 ,而使用charAt代替的话复杂度为
总结
脑筋急转弯类题目重点在于积累,其中较为常见的思考切入点包括:分类枚举、根据最终结果枚举、对构造唯一性进行分析 ...