【基础算法】常见「脑筋急转弯」类算法题合集

1,204 阅读11分钟

脑筋急转弯

算法面试中,有一类题是实现要求不高,但是思维难度很大的类型,这便是脑筋急转弯。

今天通过 55 道题目一起学习此类算法。


406. 根据身高重建队列

假设有打乱顺序的一群人站成一个队列,数组 people 表示队列中一些人的属性(不一定按顺序)。每个 people[i]=[hi,ki]people[i] = [h_i, k_i] 表示第 ii 个人的身高为 hih_i ,前面 正好 有 kik_i 个身高大于或等于 hih_i 的人。

请你重新构造并返回输入数组 people 所表示的队列。返回的队列应该格式化为数组 queue ,其中 queue[j]=[hj,kj]queue[j] = [h_j, k_j] 是队列中第 jj 个人的属性(queue[0]queue[0] 是排在队列前面的人)。

示例 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 个身高更高或者相同的人排在他前面,即编号为 01 的人。
编号为 3 的人身高为 6 ,有 1 个身高更高或者相同的人排在他前面,即编号为 1 的人。
编号为 4 的人身高为 4 ,有 4 个身高更高或者相同的人排在他前面,即编号为 0123 的人。
编号为 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]]

提示:

  • 1<=people.length<=20001 <= people.length <= 2000
  • 0<=hi<=1060 <= h_i <= 10^6
  • 0<=ki<people.length0 <= k_i < people.length
  • 题目数据确保队列可以被重建
构造 + 二分 + 树状数组

这是一道非常综合的题目。

首先根据双关键字排序:当「高度(第一维)」不同,根据高度排升序,对于高度相同的情况,则根据「编号(第二维)」排降序。

采取这样的排序规则的好处在于:在从前往后处理某个 people[i]people[i] 时,我们可以直接将其放置在「当前空位序列(从左往后统计的,不算已被放置的位置)」中的 people[i][1]+1people[i][1] + 1 位(预留了前面的 people[i][1]people[i][1] 个位置给后面的数)。

关于「空位序列」如图所示(黄色代表已被占用,白色代表尚未占用):

具体的,我们按照构造的合理性来解释双关键字排序的合理性,假设当前处理的是 people[i]people[i]

根据「高度」排升序,根据「编号」排降序:由于首先是根据「高度」排升序,因此当 people[i]people[i] 被放置在「当前空位序列」的第 people[i][1]+1people[i][1] + 1 之后,无论后面的 people[j]people[j] 如何放置,都不会影响 people[i]people[i] 的合法性:后面的数的高度都不低于 people[i][0]people[i][0],无论放在 people[i][1]+1people[i][1] + 1 前面还是后面都不会影响 people[i]people[i] 的合法性。

同时对于高度(第一维)相同,编号(第二维)不同的情况,我们进行了「降序」处理,因此「每次将 people[i]people[i] 放置在空白序列的 people[i][1]+1people[i][1] + 1 位置的」的逻辑能够沿用:

对于「高度」相同「编号」不同的情况,会被按照「从右到左」依次放置,导致了每个 people[i]people[i] 被放置时,都不会受到「高度」相同的其他 people[j]people[j] 所影响。换句话说,当 people[i]people[i] 放置时,其左边必然不存在其他高度为 people[i][0]people[i][0] 的成员。

剩下的在于,如何快速找到「空白序列中的第 kk 个位置」,这可以通过「二分 + 树状数组」来做:

对于已被使用的位置标记为 11,未使用的位置为 00,那么第一个满足「00 的个数大于等于 k+1k + 1」的位置即是目标位置,在长度明确的情况下,求 00 的个数和求 11 的个数等同,对于位置 xx 而言(下标从 11 开始,总个数为 xx),如果在 [1,x][1, x] 范围内有 k+1k + 100,等价于有 x(k+1)x - (k + 1)11

求解 [1,x][1, x] 范围内 11 的个数等价于求前缀和,即「区间查询」,同时我们每次使用一个新的位置后 ,需要对其进行标记,涉及「单点修改」,因此使用「树状数组」进行求解。

代码:

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;
    }
}
  • 时间复杂度:排序的复杂度为 O(nlogn)O(n\log{n});共要处理 nnpeople[i]people[i],每次处理需要二分,复杂度为 O(logn)O(\log{n});每次二分和找到答案后需要操作树状数组,复杂度为 O(logn)O(\log{n})。整体复杂度为 O(n×logn×logn)O(n \times \log{n} \times \log{n})
  • 空间复杂度:O(n)O(n)

423. 从英文中重建数字

给你一个字符串 s ,其中包含字母顺序打乱的用英文单词表示的若干数字(0-9)。按 升序 返回原始的数字。

示例 1:

输入:s = "owoztneoer"

输出:"012"

提示:

  • 1<=s.length<=1051 <= s.length <= 10^5
  • s[i]["e","g","f","i","h","o","n","s","r","u","t","w","v","x","z"] 这些字符之一
  • s 保证是一个符合题目要求的字符串
模拟

题目要求我们将打乱的英文单词重建为数字。

我们可以先对 s 进行词频统计,然后根据「英文单词中的字符唯一性」确定构建的顺序,最后再对答案进行排序即可。

具体的,zero 中的 z 在其余所有单词中都没出现过,我们可以先统计 zero 的出现次数,并构建 00;然后观察剩余数字,其中 eight 中的 g 具有唯一性,构建 88;再发现 six 中的 x 具有唯一性,构建 66;发现 three 中的 h 具有唯一性(利用在此之前 eight 已经被删除干净,词频中仅存在 three 对应的 h),构建 33 ...

最终可以确定一个可行的构建序列为 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);
    }
}
  • 时间复杂度:令 mm 为最终答案的长度,LL 为所有英文单词的字符总长度。构建答案的复杂度为 O(L+m)O(L + m);对构建答案进行排序复杂度为 O(mlogm)O(m\log{m})。整体复杂度为 O(mlogm)O(m\log{m})
  • 空间复杂度:O(L+m)O(L + m)

419. 甲板上的战舰

给你一个大小为 mxnm x n 的矩阵 boardboard 表示甲板,其中,每个单元格可以是一艘战舰 'X' 或者是一个空位 '.' ,返回在甲板 boardboard 上放置的 战舰 的数量。

战舰 只能水平或者垂直放置在 boardboard 上。换句话说,战舰只能按 1k1 * k11 行,kk 列)或 k1k * 1kk 行,11 列)的形状建造,其中 kk 可以是任意大小。

两艘战舰之间至少有一个水平或垂直的空位分隔 (即没有相邻的战舰)。

示例 1:

输入:board = [["X",".",".","X"],[".",".",".","X"],[".",".",".","X"]]

输出:2

示例 2:

输入:board = [["."]]

输出:0

提示:

  • m==board.lengthm == board.length
  • n==board[i].lengthn == board[i].length
  • 1<=m,n<=2001 <= m, n <= 200
  • board[i][j]board[i][j]'.''X'

进阶:你可以实现一次扫描算法,并只使用 O(1)O(1) 额外空间,并且不修改 board 的值来解决这个问题吗?

脑筋急转弯

如果「允许扫描多次」或者「使用与输入同规模的空间」的话,做法都十分简单:

  • 允许扫描多次,但空间只能 O(1)O(1):每次遇到 X 的格子,则将 X 所在的战舰修改为 -,统计完答案后,再扫描一次,将 - 恢复为 X 即可;
  • 扫描一次,但空间允许 O(mn)O(m * n):使用一个与矩阵同等大小的辅助数组 visvis 记录访问过的位置即可。

但题目要求「扫描一次」并且「空间 O(1)O(1)」,这就需要有点「脑筋急转弯」了。

注意这里的「扫描一次」是指使用一次遍历,而非要求每个单元格仅能访问一次,注意两者区别。

思考上述两种做法,我们本质 都是在战舰的首个格子进行计数,并将该战舰的所有格子进行处理,同时使用去重手段(原数组标记 或 使用辅助数组)来防止该战舰在后面遍历中被重复计数。

如果我们能够找到某种规律,直接判断出某个 X 格子是否为战舰开头,则不再需要其他去重手段。

当且仅当某个 X 格子的「上方」&「左方」不为 X 时,该格子为战舰首个格子,可以进行计数,同时需要注意当前当为 00(没有「上方」)和当前列为 00(没有「左方」)时的边界情况。

  • 一次扫描 + O(1)O(1) 代码:
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

在一个操作中,您可以选择 0<=i<nums.length0 <= i < nums.length 的任何索引 i 。将 nums[i]nums[i] 改为 nums[i]+xnums[i] + x ,其中 xx 是一个范围为 [k,k][-k, k] 的整数。对于每个索引 i ,最多 只能 应用 一次 此操作。

nums 的 分数 是 nums 中最大和最小元素的差值。 

在对  nums 中的每个索引最多应用一次上述操作后,返回 nums 的最低 分数 。

示例 1:

输入:nums = [1], k = 0

输出:0

解释:分数是 max(nums) - min(nums) = 1 - 1 = 0

提示:

  • 1<=nums.length<=1041 <= nums.length <= 10^4
  • 0<=nums[i]<=1040 <= nums[i] <= 10^4
  • 0<=k<=1040 <= k <= 10^4
脑筋急转弯

根据题意,对于任意一个数 nums[i]nums[i] 而言,其所能变化的范围为 [nums[i]k,nums[i]+k][nums[i] - k, nums[i] + k],我们需要最小化变化后的差值。而当 kk 足够大时,我们必然能够将所有数变为同一个值,此时答案为 00,而更一般的情况,我们能够缩减的数值距离为 2k2 * k,因此如果原来的最大差值为 d=maxmind = \max - \min,若 d<=2kd <= 2 * k 时,答案为 00,否则答案为 d2kd - 2 * k

代码:

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);
    }
}
  • 时间复杂度:O(n)O(n)
  • 空间复杂度:O(1)O(1)

2038. 如果相邻两个颜色均相同则删除当前颜色

总共有 nn 个颜色片段排成一列,每个颜色片段要么是 'A' 要么是 'B' 。

给你一个长度为 nn 的字符串 colors ,其中 colors[i]colors[i] 表示第 ii 个颜色片段的颜色。

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

提示:

  • 1<= colors.length<=1051 <= colors.length <= 10^5
  • colors 只包含字母 'A' 和 'B'
脑筋急转弯

根据删除规则,删除任意一个 A 不会影响可被删删除的 B 的数量,反之亦然。

因此直接统计「可删除的 A 的数量」和「可删除的 B 的数量」,分别记为 aabb,比较 aabb 的大小即可得到答案(只有 a>ba > 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;
    }
}
  • 时间复杂度:O(n)O(n)
  • 空间复杂度:使用 toCharArray 操作会产生新数组,复杂度为 O(n)O(n),而使用 charAt 代替的话复杂度为 O(1)O(1)

总结

脑筋急转弯类题目重点在于积累,其中较为常见的思考切入点包括:分类枚举、根据最终结果枚举、对构造唯一性进行分析 ...