序号 | 题目 | 难度 |
---|---|---|
11 | H 指数 | 中等 |
12 | O(1) 时间插入、删除和获取随机元素 | 中等 |
13 | 除自身以外数组的乘积 | 中等 |
14 | 加油站 | 中等 |
15 | 分发糖果 | 困难 |
11、H 指数
题目大意:
给你一个整数数组 citations ,其中 citations[i] 表示研究者的第 i 篇论文被引用的次数。计算并返回该研究者的 h 指数。
根据维基百科上 h 指数的定义:h 代表“高引用次数” ,一名科研人员的 h 指数 是指他(她)至少发表了 h 篇论文,并且 至少 有 h 篇论文被引用次数大于等于 h 。如果 h 有多种可能的值,h 指数 是其中最大的那个。
图解题意:
解题步骤:
这道题的解题思路主要是对研究者的H指数进行计算。H指数是一个衡量研究者学术影响力的指标,定义为研究者至少有h篇论文被引用了h次。换句话说,H指数是指在某个研究者的论文列表中,有h篇论文被引用了至少h次,而其他的论文被引用次数不超过h次。
为了解决这个问题,我们可以按照以下步骤进行:
- 对给定的引用次数数组进行排序,从大到小排序。
- 遍历排序后的数组,找到最大的h,使得citations[i] >= h成立,其中i是从0开始的索引值。
- 最终找到的h就是研究者的H指数。
通过这样的算法,我们可以有效地计算出研究者的H指数。这个算法的时间复杂度为O(nlogn),因为在排序之后需要遍历整个数组来计算H指数。
完整代码:
class HIndex {
public static int hIndex(int[] citations) {
Arrays.sort(citations); // 对引用次数进行排序
int h = 0;
for (int i = citations.length - 1; i >= 0; i--) {
if (citations[i] >= citations.length - i) { // 找到最大的 h
h = citations.length - i;
} else {
break;
}
}
return h;
}
注意:
if (citations[i] >= citations.length - i)
对于当前索引 i 处的引用次数 citations[i],判断是否满足条件:
引用次数大于或等于(论文总数 - 当前索引)。这里的 citations.length - i 表示引用次数大于等于 citations.length - i 的论文数量。
根据 H 指数的定义,H 指数表示至少有 h 篇论文的引用次数不少于 h。
所以,当 citations[i] >= citations.length - i
成立时,说明当前引用次数大于等于剩余的论文数量,即满足了 H 指数的条件。
12、O(1) 时间插入、删除和获取随机元素
题目大意:
实现RandomizedSet 类:
RandomizedSet() 初始化 RandomizedSet 对象
bool insert(int val) 当元素 val 不存在时,向集合中插入该项,并返回 true ;否则,返回 false 。
bool remove(int val) 当元素 val 存在时,从集合中移除该项,并返回 true ;否则,返回 false 。
int getRandom() 随机返回现有集合中的一项(测试用例保证调用此方法时集合中至少存在一个元素)。每个元素应该有 相同的概率 被返回。
解题步骤:
利用列表和哈希表的特性,以实现在常数时间内完成插入、删除和获取随机元素的操作。
- 使用一个列表
list
和一个哈希表map
来实现数据结构。 - 在初始化方法中,分别创建空的列表和哈希表。
- 对于插入操作,首先检查要插入的值是否已经存在于哈希表中,如果存在则返回 false。否则,将该值存储到哈希表中,键为值本身,值为列表的长度(即索引),然后将值添加到列表的末尾。最后返回 true。
- 对于删除操作,首先检查要删除的值是否存在于哈希表中,如果不存在则返回 false。如果存在,则根据值获取其在列表中的索引。如果该索引不是列表的最后一个位置,将列表最后一个元素的值移到要删除的元素位置上,并更新最后一个元素在哈希表中的索引。然后从哈希表中移除要删除的元素,并从列表中移除最后一个元素。最后返回 true。
- 对于获取随机元素操作,首先创建一个
java.util.Random
类的实例。然后生成一个随机的索引,范围是从 0 到列表的长度减一。最后返回该索引在列表中的元素。
完整代码:
class RandomizedSet {
private List<Integer> list; // 用于存储随机元素的列表
private Map<Integer, Integer> map; // 用于存储元素在列表中的索引
/** Initialize your data structure here. */
public RandomizedSet() {
list = new ArrayList<Integer>();
map = new HashMap<Integer, Integer>();
}
/** Inserts a value to the set. Returns true if the set did not already contain the specified element. */
public boolean insert(int val) {
if (map.containsKey(val)) { // 如果值已经存在于哈希表中,返回false
return false;
}
map.put(val, list.size()); // 将值和其在列表中的索引存储到哈希表中
list.add(val); // 将值添加到列表末尾
return true;
}
/** Removes a value from the set. Returns true if the set contained the specified element. */
public boolean remove(int val) {
if (!map.containsKey(val)) { // 如果值不存在于哈希表中,返回false
return false;
}
int index = map.get(val); // 获取值在列表中的索引
if (index < list.size() - 1) { // 如果不是列表中的最后一个元素
int lastVal = list.get(list.size() - 1); // 获取列表中的最后一个元素
list.set(index, lastVal); // 将最后一个元素放到要删除元素的位置
map.put(lastVal, index); // 更新最后一个元素在哈希表中的索引
}
map.remove(val); // 从哈希表中移除要删除的元素
list.remove(list.size() - 1); // 移除列表的最后一个元素
return true;
}
/** Get a random element from the set. */
public int getRandom() {
Random rand = new Random();
return list.get(rand.nextInt(list.size())); // 生成一个随机索引,并返回对应索引在列表中的元素
}
}
总结:
这种实现方式利用了列表和哈希表的特性,以实现在常数时间内完成插入、删除和获取随机元素的操作。
-
插入操作:
- 对于插入操作,使用哈希表来快速判断元素是否已经存在,并且可以在 O(1) 的时间复杂度内查找元素。
- 将元素添加到列表末尾,保证了插入操作的时间复杂度为 O(1)。
-
删除操作:
- 删除操作中,通过哈希表快速定位要删除元素在列表中的位置,然后将其与列表末尾元素交换,并更新哈希表中的索引信息,最后再删除末尾元素。
- 这样做避免了删除中间元素时需要移动大量元素的情况,保证了删除操作的平均时间复杂度为 O(1)。
-
获取随机元素操作:
- 获取随机元素操作通过生成一个随机索引,然后直接在列表中获取对应元素,实现了在 O(1) 时间复杂度内获取随机元素。
综上所述,这种基于列表和哈希表的实现方式结合了它们各自的优势,使得插入、删除和获取随机元素的操作都能在平均情况下达到常数时间复杂度,保证了数据结构的高效性。
13、除自身以外数组的乘积
题目大意:
给你一个整数数组 nums,返回 数组 answer ,其中 answer[i] 等于 nums 中除 nums[i] 之外其余各元素的乘积 。
题目数据 保证 数组 nums之中任意元素的全部前缀元素和后缀的乘积都在 32 位 整数范围内。
请 不要使用除法,且在 O(n) 时间复杂度内完成此题。
解题思路:
通过构建两个辅助数组来计算每个元素左侧和右侧所有元素的乘积。
除 nums[i] 之外其余各元素的乘积 。 这题的解法也就是说让左侧和右侧的数组相乘
。
题目要求返回除 nums[i] 之外其余各元素的乘积。这个乘积可以通过左侧所有元素的乘积乘以右侧所有元素的乘积来计算得到。
这种解决方法的思路是通过构建两个辅助数组来计算每个元素左侧和右侧所有元素的乘积。为什么要这样解决呢?原因如下:
-
题目要求不能使用除法,因此无法直接计算出除自身以外的所有元素的乘积。采用构建两个辅助数组的方式可以避免使用除法操作。
-
通过构建
leftProduct
数组和rightProduct
数组,我们可以利用动态规划的思想,将问题分解为两个子问题。leftProduct
数组中的每个元素表示当前元素左侧所有元素的乘积,rightProduct
数组中的每个元素表示当前元素右侧所有元素的乘积。 -
通过两次遍历数组,我们可以在时间复杂度为 O(n) 的情况下计算出
leftProduct
和rightProduct
数组。第一次遍历计算leftProduct
数组时,我们从左到右依次累积乘积;第二次遍历计算rightProduct
数组时,我们从右到左依次累积乘积。 -
最后,我们将
leftProduct
和rightProduct
数组对应位置的元素相乘,得到最终的结果数组。
这种解决方法利用了两个辅助数组的计算,避免了使用除法操作,并且在线性时间复杂度内完成了问题的求解。
解题步骤:
- 首先初始化两个数组
leftProduct
和rightProduct
,分别表示当前元素左侧所有元素的乘积和右侧所有元素的乘积。 - 遍历数组计算
leftProduct
,从左向右计算每个元素左侧所有元素的乘积。 - 再次遍历数组计算
rightProduct
,从右向左计算每个元素右侧所有元素的乘积。 - 最后将
leftProduct
和rightProduct
相乘得到最终结果,即为除自身以外数组的乘积。
完整代码:
以下是一个 Java 解法实现:
public int[] productExceptSelf(int[] nums) {
int n = nums.length;
// 初始化两个数组,leftProduct 表示当前元素左侧所有元素的乘积,rightProduct 表示当前元素右侧所有元素的乘积
int[] leftProduct = new int[n];
int[] rightProduct = new int[n];
// 计算 leftProduct 数组,leftProduct[i] 表示索引 i 左侧所有元素的乘积
leftProduct[0] = 1; //因为对于第一个元素来说,它左侧没有元素,所以乘积应该是 1
for (int i = 1; i < n; i++) {
leftProduct[i] = leftProduct[i - 1] * nums[i - 1];
}
// 计算 rightProduct 数组,rightProduct[i] 表示索引 i 右侧所有元素的乘积
rightProduct[n - 1] = 1;
for (int i = n - 2; i >= 0; i--) {
rightProduct[i] = rightProduct[i + 1] * nums[i + 1];
}
// 将 leftProduct 和 rightProduct 相乘得到最终结果
int[] answer = new int[n];
for (int i = 0; i < n; i++) {
answer[i] = leftProduct[i] * rightProduct[i];
}
return answer;
}
这段代码实现了计算除自身以外数组的乘积的功能,时间复杂度为 O(n)。
14、加油站
题目大意:
在一条环路上有 n 个加油站,其中第 i 个加油站有汽油 gas[i] 升。
你有一辆油箱容量无限的的汽车,从第 i 个加油站开往第 i+1 个加油站需要消耗汽油 cost[i] 升。你从其中的一个加油站出发,开始时油箱为空。
给定两个整数数组 gas 和 cost ,如果你可以按顺序绕环路行驶一周,则返回出发时加油站的编号,否则返回 -1 。如果存在解,则 保证 它是 唯一 的。
解题思路:
当处理类似的加油站环行问题时,我们可以使用贪心算法来解决。
- 如果总剩余的汽油量能够满足整个环绕一圈的消耗,那么一定存在解。
- 通过一次遍历整个加油站数组,计算总剩余汽油量和当前油箱剩余汽油量。
- 如果总剩余汽油量大于等于0,则一定存在解。否则不存在解。
贪心算法(Greedy Algorithm)是一种在每一步选择中都采取当前状态下最优决策的算法。
解题步骤:
- 初始化变量
total
和tank
分别表示总剩余汽油量和当前油箱剩余汽油量。 - 从第一个加油站开始遍历,计算当前加油站的汽油剩余量,并更新
total
和tank
的值。 - 如果
tank
小于0,说明无法从当前加油站出发到达下一个加油站,需要更新起始加油站为下一个加油站,并将tank
重置为0。 - 最后,如果
total
大于等于0,返回起始加油站的编号,否则返回-1。
这个解法的关键在于通过一次遍历计算总剩余汽油量和当前油箱剩余汽油量,而无需额外的空间开销。这样可以将时间复杂度降低至 O(n)。
完整代码:
下面是一个解决这个问题的Java代码示例:
public int canCompleteCircuit(int[] gas, int[] cost) {
int total = 0; // 总剩余油量
int tank = 0; // 当前油箱剩余油量
int start = 0; // 起始加油站编号
for (int i = 0; i < gas.length; i++) {
int diff = gas[i] - cost[i]; // 当前加油站的油量与消耗之差
total += diff; // 更新总剩余油量
tank += diff; // 更新当前油箱剩余油量
if (tank < 0) { // 如果当前油箱剩余油量为负
start = i + 1; // 更新起始加油站为下一个加油站
tank = 0; // 将当前油箱剩余油量重置为0
}
}
return total >= 0 ? start : -1; // 如果总剩余油量大于等于0,返回起始加油站编号,否则返回-1
}
这段代码通过一次遍历数组,计算总剩余油量 total
和当前油箱剩余油量 tank
。如果在某个加油站油箱剩余油量为负,说明无法从当前加油站到达下一个加油站,则更新起始加油站为下一个加油站,并将当前油箱剩余油量重置为0。最后,如果总剩余油量大于等于0,说明存在解,返回起始加油站编号,否则返回-1。
代码解析:
这段代码是用来解决加油站环行问题的贪心算法实现。让我们一步一步解析这段代码:
-
total
表示总剩余油量,tank
表示当前油箱剩余油量,start
表示起始加油站编号,初始值都为0。 -
使用 for 循环遍历每个加油站:
- 计算当前加油站的油量与消耗之差,即
diff = gas[i] - cost[i]
。 - 将
diff
加到total
中,表示更新总剩余油量。 - 将
diff
加到tank
中,表示更新当前油箱剩余油量。
- 计算当前加油站的油量与消耗之差,即
-
判断当前油箱剩余油量是否小于0:
- 如果小于0,说明无法从当前加油站出发到达下一个加油站,需要选择下一个加油站为起始加油站,因此更新
start
为i + 1
,并将tank
重置为0。
- 如果小于0,说明无法从当前加油站出发到达下一个加油站,需要选择下一个加油站为起始加油站,因此更新
-
循环结束后,判断总剩余油量是否大于等于0:
- 如果大于等于0,说明存在解,返回起始加油站编号
start
。 - 如果小于0,说明不存在解,返回-1。
- 如果大于等于0,说明存在解,返回起始加油站编号
这段代码的核心思想
是通过一次遍历计算总剩余油量和当前油箱剩余油量,根据当前油箱状态来动态选择起始加油站,并最终判断是否存在解。
15、分发糖果
题目大意:
n 个孩子站成一排。给你一个整数数组 ratings 表示每个孩子的评分。
你需要按照以下要求,给这些孩子分发糖果:
每个孩子至少分配到 1 个糖果。
相邻两个孩子评分更高的孩子会获得更多的糖果。
请你给每个孩子分发糖果,计算并返回需要准备的 最少糖果数目 。
解题思路:
这个问题同样可以使用贪心算法来解决。贪心算法的基本思想是每一步都选择当前最优的方案,通过局部最优选择达到全局最优。
- 首先,初始化糖果数组
candies
全部为1,表示每个孩子至少分配到一个糖果。 - 从左往右遍历一次
ratings
数组,如果当前孩子的评分比前一个孩子高,则将当前孩子的糖果数量更新为前一个孩子的糖果数量加1,即candies[i] = candies[i-1] + 1
。这样可以保证相邻两个孩子评分更高的孩子获得更多的糖果。 - 接着,从右往左再遍历一次
ratings
数组,如果当前孩子的评分比后一个孩子高,并且当前孩子的糖果数量不大于后一个孩子的糖果数量,则将当前孩子的糖果数量更新为后一个孩子的糖果数量加1,即candies[i] = Math.max(candies[i], candies[i+1]+1)
。这样可以处理右边评分高于左边的情况,确保满足条件。 - 最后,将
candies
数组中所有元素的和作为最少糖果数目返回。
这种贪心策略能够保证每个孩子都至少分配到1个糖果,并且满足相邻两个孩子评分更高的孩子获得更多的糖果的要求。通过两次遍历,可以得到最优解。算法的时间复杂度为O(n),其中n为孩子的数量。
完整代码:
下面是Java代码实现:
class Solution {
public int candy(int[] ratings) {
int n = ratings.length;
int[] candies = new int[n];
Arrays.fill(candies, 1); // 初始化糖果数组为1
for (int i = 1; i < n; i++) {
if (ratings[i] > ratings[i-1]) {
candies[i] = candies[i-1] + 1;
}
}
for (int i = n-2; i >= 0; i--) {
if (ratings[i] > ratings[i+1]) {
candies[i] = Math.max(candies[i], candies[i+1]+1);
}
}
int sum = 0;
for (int c : candies) {
sum += c;
}
return sum;
}
}
经过两次遍历之后,candies 数组中每个元素表示对应孩子的最终糖果数量。最后,将 candies 数组中所有元素的和作为最少糖果数目返回即可。
疑惑解答:
疑问:为啥要两次遍历?
两次遍历的目的主要是为了解决从左到右和从右到左两个方向上的矛盾。
如果只进行一次遍历,例如从左到右遍历ratings数组,那么我们只能保证满足相邻两个孩子评分更高的孩子获得更多糖果的要求,但无法处理右边评分高于左边的情况。而如果只从右到左遍历,同样也无法满足从左到右的需求。
因此,我们需要进行两次遍历,通过贪心策略,先从左到右遍历一次,然后从右到左遍历一次,这样就可以同时满足两个方向上的需求,确保每个孩子都分配到最优数量的糖果。
示例如下:
让我们通过一个例子来说明为什么需要两次遍历。
假设有5个孩子,他们的评分数组为:[1, 0, 2]。
首先,我们初始化糖果数组为[1, 1, 1]。
第一次遍历(从左到右):
- 比较第二个孩子的评分0和第一个孩子的评分1,因为0小于1,所以第二个孩子的糖果数量保持为1,此时糖果数组为[1, 1, 1]。
- 比较第三个孩子的评分2和第二个孩子的评分0,因为2大于0,所以第三个孩子的糖果数量更新为前一个孩子糖果数量加1,即糖果数组变为[1, 1, 2]。
第二次遍历(从右到左):
- 比较第二个孩子的评分0和第三个孩子的评分2,因为0小于2,所以不做改变;
- 比较第一个孩子的评分1和第二个孩子的评分0,因为1 > 0,则
candies[i] = Math.max(candies[i], candies[i+1]+1);
即糖果数组变为[2, 1, 2]。
最终,糖果数组为[2, 1, 2],每个孩子分配到的糖果数量分别为2、1、2,满足了题目要求。这个例子说明了通过两次遍历可以得到最优的糖果分配方案。