智力发散题
推荐刷题顺序:
LeetCode #面试题 17.09 第K个数
LeetCode #859 亲密字符串
LeetCode #860 柠檬水找零
LeetCode #969 煎饼排序
LeetCode #621 任务调度
LeeCode #338 比特位计数
1. LeetCode #面试题 17.09 第K个数
题目描述:
有些数的素因子只有 3,5,7,请设计一个算法找出第 k 个数。注意,不是必须有这些素因子,而是必须不包含其他的素因子。例如,前几个数按顺序应该是 1,3,5,7,9,15,21。
解题思路:
- 为了叙述方便,我们就把符合题目要求的这些数叫做丑数。
- 不难发现,一个丑数总是由前面的某一个丑数 x3 / x5 / x7 得到。
反过来说也是一样的,一个丑数 x3 / x5 / x7 就会得到某一个更大的丑数。 - 如果把丑数数列叫做 ugly[i],那么考虑一下三个数列:
- 1. ugly[0]*3,ugly[1]*3,ugly[2]*3,ugly[3]*3,ugly[4]*3,ugly[5]*3……
- 2. ugly[0]*5,ugly[1]*5,ugly[2]*5,ugly[3]*5,ugly[4]*5,ugly[5]*5……
- 3. ugly[0]*7,ugly[1]*7,ugly[2]*7,ugly[3]*7,ugly[4]*7,ugly[5]*7……
- 上面这个三个数列合在一起就形成了新的、更长的丑数数列。
- 如果合在一起呢?这其实就是一个合并有序线性表的问题。
- 定义三个index 分别指向上面三个数列,下一个丑数一定是三个 index 代表的值中最小的那个,然后相应 index++ 即可。
动画图解:
首先第一个丑数是1,将其放入数组中(我图画错了数组下标应该从0开始,但是意思不变,懒得改了),然后定义三个下标分别指向数组中第一个元素,通过下标获取数组元素分别与对应的素数相乘,比较取最小值:
class Solution {
public int getKthMagicNumber(int k) {
int[] arr = new int[k];
arr[0] = 1;
int num = 1;
int p3 = 0, p5 = 0, p7 = 0;
while (num < k) {
int value3 = arr[p3] * 3;
int value5 = arr[p5] * 5;
int value7 = arr[p7] * 7;
int minValue = Math.min(Math.min(value3, value5), value7);
arr[num] = minValue;
if (value3 == minValue) p3++;
if (value5 == minValue) p5++;
if (value7 == minValue) p7++;
num++;
}
return arr[arr.length - 1];
}
}
2. LeetCode #859 亲密字符串
题目描述:
给定两个由小写字母构成的字符串 A 和 B ,只要我们可以通过交换 A 中的两个字母得到与 B 相等的结果,就返回 true ;否则返回 false 。
交换字母的定义是取两个下标 i 和 j (下标从 0 开始),只要 i!=j 就交换 A[i] 和 A[j] 处的字符。例如,在 “abcd” 中交换下标 0 和下标 2 的元素可以生成 “cbad” 。
解题思路:
- 只有两种情况我们才认为两个字符串为亲密字符串
- 只有两处不同,并且两处不同是可交换的
- 两个字符串完全相同,并且至少有一个字符出现两次以上(比如aab,交换第一个和第二个字符仍然相等,也属于亲密字符串)
以下是判断两处不同,并且两处不同是可交换的图解:
首先寻找A字符串和B字符串之间不同的位置
当找到第一处不同点时,首先判断changeA和changeB是否为null,确保是第一个不同点,然后分别记录两个不同的字符
继续查找下一个不同点,当找到下一个不同点的时候,判断changeA是否和字符串B第二处不同点的字符相等,changeB是否和字符串A第二处不同点的字符相等
- 不相等说明两处不同点是不能通过交换保证字符串相等的,返回false
相等,如果当前字符串还没有遍历完,还需要保证之后的每一个字符都相等才能返回true
class Solution {
public boolean buddyStrings(String a, String b) {
// 长度不一样肯定不是亲密字符串
if (a.length() != b.length()) {
return false;
}
// 如果两个字符串完全相等,判断字符串中是否有重复的字符
if (a.equals(b)) {
int[] charNum = new int[26];
for (char c : a.toCharArray()) {
int i = c - 'a';
if (charNum[i] != 0) {
return true;
}
charNum[i] = 1;
}
return false;
}
char[] charsA = a.toCharArray();
char[] charsB = b.toCharArray();
int first = -1, second = -1;// 记录不同点位置
for (int i = 0; i < a.length(); i++) {
if (charsA[i] != charsB[i]) {
if (first == -1) {
first = i;
} else if (second == -1) {
second = i;
} else {
return false;// 说明找到第三处不同点直接返回false
}
}
}
// 走完循环有可能只找到一个不同点,所以要判断下second不为-1
return second != -1 && charsA[first] == charsB[second] && charsA[second] == charsB[first];
}
}
3. LeetCode #860 柠檬水找零
题目描述:
在柠檬水摊上,每一杯柠檬水的售价为 5 美元。
顾客排队购买你的产品,(按账单 bills 支付的顺序)一次购买一杯。
每位顾客只买一杯柠檬水,然后向你付 5 美元、10 美元或 20 美元。你必须给每个顾客正确找零,也就是说净交易是每位顾客向你支付 5 美元。
注意,一开始你手头没有任何零钱。
如果你能给每位顾客正确找零,返回 true ,否则返回 false 。
解题思路:
由于顾客只可能给你三个面值的钞票,而且我们一开始没有任何钞票,因此我们拥有的钞票面值只可能是 5 美元,10 美元和 20 美元三种。基于此,我们可以进行如下的分类讨论。
- 5 美元,由于柠檬水的价格也为 5 美元,因此我们直接收下即可。
- 10 美元,我们需要找回 5 美元,如果没有 5 美元面值的钞票,则无法正确找零。
- 20 美元,我们需要找回 15 美元,此时有两种组合方式,一种是一张 10 美元和 5 美元的钞票,一种是 3 张 5 美元的钞票,如果两种组合方式都没有,则无法正确找零。
当可以正确找零时,两种找零的方式中我们更倾向于第一种,即如果存在 5 美元和 10 美元,我们就按第一种方式找零,否则按第二种方式找零,因为需要使用 5 美元的找零场景会比需要使用 10 美元的找零场景多,我们需要尽可能保留 5 美元的钞票。
基于此,我们维护两个变量 five 和 ten 表示当前手中拥有的 5 美元和 10 美元钞票的张数,从前往后遍历数组分类讨论即可。
无法找零的情况:
class Solution {
public boolean lemonadeChange(int[] bills) {
int five = 0;
int ten = 0;
for (int bill : bills) {
switch (bill) {
case 5:
// 5元直接收走
five++;
break;
case 10:
if (five == 0) return false;
// 10元找5元
five--;
ten++;
break;
case 20:
if (five > 0 && ten > 0) {
//20元优先找一张10和一张5
five--;
ten--;
} else if (five >= 3) {
// 否则找3张5
five -= 3;
} else {
return false;
}
break;
}
}
return true;
}
}
4. LeetCode #969 煎饼排序
题目描述:
给你一个整数数组 arr ,请使用 煎饼翻转 完成对数组的排序。
一次煎饼翻转的执行过程如下:
- 选择一个整数 k ,1 <= k <= arr.length
- 反转子数组 arr[0…k-1](下标从 0 开始)
例如,arr = [3,2,1,4] ,选择 k = 3 进行一次煎饼翻转,反转子数组 [3,2,1] ,得到 arr = [1,2,3,4] 。
以数组形式返回能使 arr 有序的煎饼翻转操作所对应的 k 值序列。任何将数组排序且翻转次数在 10 * arr.length 范围内的有效答案都将被判断为正确。
解题思路:
- 煎饼排序简单来说,就是每次只能反转数组中的第一个元素到第K个元素的子数组,然后我们每完成反转一次,就将k值记录下来,直到这个数组变成有序递增,就输出我们记录的所有k的值。
- 每次将第N大的元素先翻转到第1位,再翻转到第N位,这样第N位就无需在后续进程中再进行处理,只需要考虑前N-1位即可。
- 由于每个元素只需要2次翻转即可归位,因此所需的次数最多只需2N次,符合题目需求。
- 对于这种做法,可以做些小优化:
- 一是可以去除最后一次值“1”的翻转(值为“1”的翻转相当于未操作);
- 二是可以跳过已经在正确位置上的元素。
- 对于煎饼排序的最优解,在《编程之美》中有更加详细的讨论,感兴趣的同学可以自行阅读。
找到次大值3:
class Solution {
public List<Integer> pancakeSort(int[] arr) {
List<Integer> result = new ArrayList<>();
// 题意可知,最大值就是数组的个数
// 我们定义的i既控制循环次数,也代表当前循环的最(次)大值
for (int i = arr.length; i > 1; i--) {// 最后一个1可以不处理
// 寻找最大值索引位
int index = 0;
while (arr[index] != i) {
index++;
}
if (index == i - 1) {
// 第一种情况,当前值已经在正确的位置,不要反转
continue;
} else if (index == 0) {
// 当前值已经在队首,只需要反转一次
flip(arr, i - 1);
result.add(i);
} else {
// 先将次大值反转到队首,再反转到i-1
flip(arr, index);
result.add(index + 1);
flip(arr, i - 1);
result.add(i);
}
}
return result;
}
// 翻转
private void flip(int[] arr, int k) {
for (int i = 0, j = k; j > i; i++, j--) {
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
}
}
5. LeetCode #621 任务调度
题目描述:
给你一个用字符数组 tasks 表示的 CPU 需要执行的任务列表。其中每个字母表示一种不同种类的任务。任务可以以任意顺序执行,并且每个任务都可以在 1 个单位时间内执行完。在任何一个单位时间,CPU 可以完成一个任务,或者处于待命状态。
然而,两个 相同种类 的任务之间必须有长度为整数 n 的冷却时间,因此至少有连续 n 个单位时间内 CPU 在执行不同的任务,或者在待命状态。
你需要计算完成所有任务所需要的 最短时间 。
解题思路:
- 思路一:
按照时间顺序,依次给每一个时间单位分配任务。那么如果当前有多种任务不在冷却中,那么我们应该如何挑选执行的任务呢?直觉上,我们应当选择剩余执行次数最多的那个任务,将每种任务的剩余执行次数尽可能平均,使得 CPU 处于待命状态的时间尽可能少。(具体实现与证明略) - 思路二:
相同任务会有时间执行间隔,中间插入其他任务能够节省时间
有数量大于1的任务都应该与最大任务数量的任务间隔相排-
我们首先考虑所有任务种类中执行次数最多的那一种,有这样的任务列表{“A”: 5, “B”: 5, “C”: 3, “D”: 4},且冷却时间n=3为例。
-
记执行次数最多的任务执行次数为max,那么无论如何安排,任务A都至少要花费
(max - 1) * (1 + n) + 1
的时间,即(5-1)*(1+3)+1,我们使用一个宽为 n+1 的矩阵可视化地展现执行 A 的时间点:转换成矩阵:
蓝色为冷却时间 -
如果还有其它也需要执行max次的任务,我们也需要将它们依次排布成列
如果需要执行 max 次的任务的数量为 maxCount,那么类似地可以得到对应的总时间为:
(max − 1)(n + 1) + maxCount
-
处理完执行次数为 max 次的任务,剩余任务的执行次数一定都小于max,那么我们应当如何将它们放入矩阵中呢?
一种方式是,从倒数第二行开始,先放入靠左侧的列,同一列中先放入下方的行,依次放入每一种任务,并且同一种任务需要连续地填入。
例如还有任务 C,D时,我们会按照下图的方式依次放入这些任务。 -
对于任意一种任务而言,一定不会被放入同一行两次(否则说明该任务的执行次数大于等于max),并且由于我们是按照列优先的顺序放入这些任务,因此任意两个相邻的任务之间要么间隔 n(例如上图中位于同一列的相同任务),要么间隔 n+1(例如上图中第一行和第二行的D),都是满足题目要求的。因此如果我们按照这样的方法填入所有的任务,那么就可以保证总时间不变,仍然为:
(max − 1)(n + 1) + maxCount
-
考虑一种情况,任务列表{“A”: 5, “B”: 5, “C”: 3, “D”: 4}中多了一种任务E,执行次数为3,按照上面方式排列,则会超出n+1列:
-
此时n+1=4,但是我们填了5列,看上去我们需要 (5 - 1) * 5 + 2 = 22的时间来执行所有任务,
但实际上如果我们填「超出」了 n+1 列,那么所有的 CPU 待命状态都是可以省去的(图中红色部分)
。这是因为 CPU 待命状态本身只是为了规定任意两个相邻任务的执行间隔至少为 n,但如果列数超过了 n+1,那么就算没有这些待命状态,任意两个相邻任务的执行间隔肯定也会至少为 n。此时,总执行时间就是任务的总数task。 -
因此,在任意的情况下,需要的最少时间就是 (max - 1)(n + 1) + maxCount 和 task 中的较大值。
-
因此我们只需要如下做:
- 统计出现次数最多的任务的次数
- 统计一共有多少种任务出现次数和最多次数一样次数的任务数量
- 最终在 任务总数量 和 格子数量 之间取最大值
class Solution {
public int leastInterval(char[] tasks, int n) {
// 统计每种任务执行次数
int[] num = new int[26];
for (char task : tasks) {
num[task - 'A'] += 1;
}
// 排序
Arrays.sort(num);
// 统计有几种任务次数 和 最大次数一样
int maxCount = 1;
for (int i = 24; i >= 0 && num[25] == num[i]; i--, maxCount++);
return Math.max(tasks.length, (num[25] - 1) * (n + 1) + maxCount);
}
}
6. LeeCode #338 比特位计数
题目描述:
给定一个非负整数 num。对于 0 ≤ i ≤ num 范围中的每个数字 i ,计算其二进制数中的 1 的数目并将它们作为数组返回。
解题思路:
- x和x-1,由于从x二进制位的最后一个1开始,和x-1不一样
- 如果做与运算的话可以得到一个相对于x二进制位刚好少一个1的数的二进制位
- 因此我们可以把 x&(x-1),看成去掉x二进制最后一位1的操作
- 设f(x)可以得到x的二进制位中1的个数,那么推出公式
f(x) - f( x&(x-1) ) = 1
class Solution {
public int[] countBits(int num) {
// 111000 56
// 110111 55
// 做与运算
// 110000 48
// 56 比 48 多一个1
// f(x) - f(x & (x -1)) = 1
int[] result = new int[num + 1];
result[0] = 0;
for (int i = 1; i <= num; i++) {
result[i] = result[i & (i - 1)] + 1;
}
return result;
}
}
这个做法的时间复杂度是O(n)