6188. 按身高排序
给你一个字符串数组 names
,和一个由 互不相同 的正整数组成的数组 heights
。两个数组的长度均为 n
。
对于每个下标 i
,names[i]
和 heights[i]
表示第 i
个人的名字和身高。
请按身高 降序 顺序返回对应的名字数组 names
。
示例
输入:names = ["Mary","John","Emma"], heights = [180,165,170]
输出:["Mary","Emma","John"]
解释:Mary 最高,接着是 Emma 和 John 。
思路
手写一个排序即可。也可以先将姓名和身高进行合并(两个属性合并成一个Pair),形成一个数组,然后调API根据身高排序。
// Java(手写排序)
class Solution {
public String[] sortPeople(String[] names, int[] heights) {
quickSort(names, heights, 0, heights.length - 1);
return names;
}
private void quickSort(String[] s, int[] h, int l, int r) {
if (l >= r) return ;
int x = h[l + r >> 1], i = l - 1, j = r + 1;
while (i < j) {
do i++; while (h[i] > x);
do j--; while (h[j] < x);
if (i < j) swap(s, h, i, j);
}
quickSort(s, h, l, j);
quickSort(s, h, j + 1, r);
}
private void swap(String[] s, int[] h, int i, int j) {
String t = s[i];
s[i] = s[j];
s[j] = t;
int tt = h[i];
h[i] = h[j];
h[j] = tt;
}
}
// C++(属性合并后调API排序):
class Solution {
public:
vector<string> sortPeople(vector<string>& names, vector<int>& heights) {
vector<pair<int, string>> q;
for (int i = 0; i < heights.size(); i++) {
// 身高从大到小排序, 身高存成相反数即可
q.push_back({-heights[i], names[i]});
}
// 默认按照pair的第一个关键字排序
sort(q.begin(), q.end());
vector<string> ans;
for (auto &p : q) ans.push_back(p.second);
return ans;
}
};
还有另一种思路,自己周赛时没想到(y总牛逼!),可以直接对下标进行排序(这样效率会高一些)。
// C++
class Solution {
public:
vector<string> sortPeople(vector<string>& names, vector<int>& heights) {
vector<int> idx; // 下标
for (int i = 0; i < names.size(); i++) idx.push_back(i);
// 按照身高从高到底, 对下标进行排序
sort(idx.begin(), idx.end(), [&](int a, int b) {
return heights[a] > heights[b];
});
vector<string> ans;
for (int i : idx) ans.push_back(names[i]);
return ans;
}
};
6189. 按位与最大的最长子数组
给你一个长度为 n
的整数数组 nums
。
考虑 nums
中进行 按位与(bitwise AND) 运算得到的值 最大 的 非空 子数组。
- 换句话说,令
k
是nums
任意 子数组执行按位与运算所能得到的最大值。那么,只需要考虑那些执行一次按位与运算后等于k
的子数组。
返回满足要求的 最长 子数组的长度。
数组的按位与就是对数组中的所有数字进行按位与运算。
子数组 是数组中的一个连续元素序列。
示例
输入:nums = [1,2,3,3,2,2]
输出:2
解释:
子数组按位与运算的最大值是 3 。
能得到此结果的最长子数组是 [3,3],所以返回 2 。
思路
乍得一看,以为又是一道考察位运算的题。仔细分析后,发现不用。由于执行与运算,二进制位为1的数量,一定是不变或减少的。即,与运算的结果一定会不变或变小。那么任意子数组执行与运算能得到的最大值,一定是尽可能的不执行与运算。即,每一个数字x
所在的任意子数组,执行与运算能得到的最大值,一定<= x
。
那么我们能够很轻易地得到这样的结论:任意子数组执行与运算能得到的最大值,一定是整个数组中最大的那个数字。
那么我们只需要遍历一次,维护当前最大的数字,并求一下最大数字连续出现的次数的最大值即可。
//Java
class Solution {
public int longestSubarray(int[] nums) {
int ans = 0, cnt = 0, max = 0;
for (int i = 0; i < nums.length; i++) {
if (nums[i] > max) {
// 出现更大的数字, 重置ans
max = nums[i];
ans = cnt = 1;
} else if (nums[i] == max) cnt++; // 连续最大值计数++
else {
ans = Math.max(ans, cnt);
cnt = 0; // 清零
}
}
ans = Math.max(ans, cnt);
return ans;
}
}
// C++
class Solution {
public:
int longestSubarray(vector<int>& nums) {
int v = 0;
for (auto &i : nums) v = max(v, i); // 先找最大值
int ans = 0;
// 找最大值的连续区间
for (int i = 0, j = 0; i < nums.size(); i++) {
if (nums[i] == v) {
j++;
ans = max(ans, j);
} else {
j = 0;
}
}
return ans;
}
};
6190. 找到所有好下标
给你一个大小为 n
下标从 0 开始的整数数组 nums
和一个正整数 k
。
对于 k <= i < n - k
之间的一个下标 i
,如果它满足以下条件,我们就称它为一个 好 下标:
- 下标
i
之前 的k
个元素是 非递增的 。 - 下标
i
之后 的k
个元素是 非递减的 。
按 升序 返回所有好下标。
示例
输入:nums = [2,1,1,1,3,4,1], k = 2
输出:[2,3]
解释:数组中有两个好下标:
- 下标 2 。子数组 [2,1] 是非递增的,子数组 [1,3] 是非递减的。
- 下标 3 。子数组 [1,1] 是非递增的,子数组 [3,4] 是非递减的。
注意,下标 4 不是好下标,因为 [4,1] 不是非递减的。
思路
前后缀分解
大体是求解这样一个东西:对于范围为[k, n - k)
的下标i
,是否满足i
的前k
个元素非递增,i
的后k
个元素非递减。
那么我们只需要对每个位置,求一下这个位置左侧非递增序列的最大长度,以及这个位置右侧非递减的序列的最大长度即可。
用left[i]
表示,以i
为终点的(不包含i
),非递增序列的最大长度;
用right[i]
表示,以i
为起点的(不包含i
),非递减序列的最大长度。
只需要遍历两次进行求解即可。
class Solution {
public List<Integer> goodIndices(int[] nums, int k) {
int n = nums.length;
int[] left = new int[n];
int[] right = new int[n];
int cnt = 1; // 记录某个位置左侧/右侧, 满足条件的连续序列的长度, 注意不能包含这个位置本身
// 故从第二个位置开始, 且cnt初始值为1
for (int i = 1; i < n; i++) {
left[i] = cnt;
if (nums[i] <= nums[i - 1]) cnt++;
else cnt = 1;
}
cnt = 1;
for (int i = n - 2; i >= 0; i--) {
right[i] = cnt;
if (nums[i] <= nums[i + 1]) cnt++;
else cnt = 1;
}
List<Integer> ans = new ArrayList<>();
for (int i = k; i < n - k; i++) {
if (left[i] >= k && right[i] >= k) ans.add(i);
}
return ans;
}
}
----贴一下y总的讲解思路,感觉更加清晰。
设f[i]
表示以i
为终点的最长非递增序列的长度,状态转移方程如下
- 若
num[i - 1] < num[i]
,此时递增,则f[i] = 1
- 若
num[i - 1] >= num[i]
,此时f[i] = f[i - 1] + 1
同理,设g[i]
表示以i
为起点的最长非递减序列的长度,则状态转移方程如下
- 若
num[i + 1] < num[i]
,此时递减,则g[i] = 1
- 若
num[i + 1] >= num[i]
,此时g[i] = g[i + 1] + 1
// C++
class Solution {
public:
vector<int> goodIndices(vector<int>& nums, int k) {
int n = nums.size();
vector<int> f(n), g(n); // 开两个vector
for (int i = 0; i < n; i++) {
f[i] = 1;
if (i > 0 && nums[i - 1] >= nums[i]) f[i] = f[i - 1] + 1;
}
for (int i = n - 1; i >= 0; i--) {
g[i] = 1;
if (i < n - 1 && nums[i + 1] >= nums[i]) g[i] = g[i + 1] + 1;
}
vector<int> ans;
for (int i = k; i < n - k; i++) {
if (f[i - 1] >= k && g[i + 1] >= k) ans.push_back(i);
}
return ans;
}
};
6191. 好路径的数目
给你一棵 n
个节点的树(连通无向无环的图),节点编号从 0
到 n - 1
且恰好有 n - 1
条边。
给你一个长度为 n
下标从 0 开始的整数数组 vals
,分别表示每个节点的值。同时给你一个二维整数数组 edges
,其中 edges[i] = [ai, bi]
表示节点 ai
和 bi
之间有一条 无向 边。
一条 好路径 需要满足以下条件:
- 开始节点和结束节点的值 相同 。
- 开始节点和结束节点中间的所有节点值都 小于等于 开始节点的值(也就是说开始节点的值应该是路径上所有节点的最大值)。
请你返回不同好路径的数目。
注意,一条路径和它反向的路径算作 同一 路径。比方说, 0 -> 1
与 1 -> 0
视为同一条路径。单个节点也视为一条合法路径。
示例
输入:vals = [1,3,2,1,3], edges = [[0,1],[0,2],[2,3],[2,4]]
输出:6
解释:总共有 5 条单个节点的好路径。
还有 1 条好路径:1 -> 0 -> 2 -> 4 。
(反方向的路径 4 -> 2 -> 0 -> 1 视为跟 1 -> 0 -> 2 -> 4 一样的路径)
注意 0 -> 2 -> 3 不是一条好路径,因为 vals[2] > vals[0] 。
n == vals.length
1 <= n <= 3 * 10^4
0 <= vals[i] <= 10^5
edges.length == n - 1
edges[i].length == 2
0 <= ai, bi < n
ai != bi
edges
表示一棵合法的树。
思路
周赛当天这道题又坐牢了1个小时。尝试了简单的DFS深搜后发现不太好运用在这道题上面。于是转换了下思路。既然好路径的定义是起点和终点都要相同。那么好路径一定只会出现在值相同的那些点之间。
每个点以自身为起点和终点能够形成一个好路径,我们这里只讨论路径长度>0
的,即路径上至少有2个点以上的。
我的想法是遍历vals
数组,对每个值进行统计计数。然后筛选出其中出现次数>=2
的那些值。假设值为a
的点一共有3个,a1
,a2
,a3
,那么可能的好路径的方案就可能有 , 就是从3个中选2个点。那么更通用的来说,若值为x
的点一共有n个,那么由值x
构成的好路径的方案可能有 。
那么我们遍历全部可能的好路径,依次判断每个组合是否构成一个好路径(从起点和终点进行双向BFS,进行碰撞检测)。
class Solution {
// 树的邻接表存储
final int N = 100000;
int[] h = new int [N];
int[] e = new int [2 * N];
int[] ne = new int[2 * N];
int idx = 0;
int ans = 0;
int n;
public int numberOfGoodPaths(int[] vals, int[][] edges) {
Arrays.fill(h, -1);
n = vals.length;
for (int[] e : edges) {
int a = e[0], b = e[1];
add(a, b);
add(b, a);
}
Map<Integer, List<Integer>> map = new HashMap<>();
Set<Integer> possible = new HashSet<>();
for (int i = 0; i < n; i++) {
if (!map.containsKey(vals[i])) map.put(vals[i], new ArrayList<>());
map.get(vals[i]).add(i);
if (map.get(vals[i]).size() > 1) possible.add(vals[i]);
}
ans = n; // 每个点单独都是一个好路径
// 对于possible里的节点, 找到两两之间的路径
for (int v : possible) {
List<Integer> list = map.get(v);
for (int i = 0; i < list.size(); i++) {
for (int j = i + 1; j < list.size(); j++) {
// 判断两个点之间是否构成好路径
if (isValid(list.get(i), list.get(j), vals)) ans++;
}
}
}
return ans;
}
private boolean isValid(int i, int j, int[] vals) {
boolean[] sti = new boolean[n];
boolean[] stj = new boolean[n];
Queue<Integer> q1 = new LinkedList<>();
Queue<Integer> q2 = new LinkedList<>();
q1.offer(i); q2.offer(j);
sti[i] = stj[j] = true;
while (!q1.isEmpty() || !q2.isEmpty()) {
if (!q1.isEmpty()) {
int x = q1.poll();
if (stj[x]) return true;
for (int k = h[x]; k != -1 ; k = ne[k]) {
int u = e[k];
if (sti[u]) continue;
if (vals[u] <= vals[i]) {
sti[u] = true;
q1.offer(u);
}
}
}
if (!q2.isEmpty()) {
int x = q2.poll();
if (sti[x]) return true;
for (int k = h[x]; k != -1 ; k = ne[k]) {
int u = e[k];
if (stj[u]) continue;
if (vals[u] <= vals[i]) {
stj[u] = true;
q2.offer(u);
}
}
}
}
return false;
}
private void add(int a, int b) {
e[idx] = b;
ne[idx] = h[a];
h[a] = idx++;
}
}
这样的方法,在枚举所有好路径方案时,时间复杂度已经达到了 ,对每一个好路径方案进行合法性判断时,又需要的复杂度。而这题n
的最大值在10^4
级别,则肯定会超时了。
当天我在最后2分钟进行了尝试提交,果不其然得到了TLE。
下面看看大佬的做法
------y总说:有点类似于树的分治问题,但是树的分治属于竞赛范畴,需要找树的重心,比较难,代码也很长,而力扣上的题目通常是面向面试。所以我们往树的遍历这些方向上去想。这道题的思路具有一定跳跃性,有点类似于求解最少生成树的Kruskal算法。
此题类似于Acwing中的346题,事后可以加以练习。
我们可以将所有的点,按照值(vals
),从小到大排序。那么值相同的点,一定在一段连续的区间内。假设这段区间内所有点的值都为x
,那么这段区间内的两两点之间,满足了好路径的第一个条件,起点和终点的值相同;至于第二个条件,路径中的点的值都要小于等于两个端点。那么好路径中的点的值,都要<=x
。则好路径中的点,只能从这段区间之前的点当中构成。那么,我们处理每个值=x
的点,尝试将该点与其邻接点进行连通(只连接那些值<=x
的邻接点)。那么对于好路径的第二个条件,我们只需要判断两个值为x
的点,是否属于同一个连通块即可(这就可以用并查集来做)。
然后对于=x
的这段区间,我们判断有多少对节点可以构成好路径,就要判断每一对节点之间的连通性。
若采用暴力枚举(共 中可能的点对),则复杂度是,由于采用了并查集,在我们将所有<=x
的点全部连接好之后。我们可以将这些值=x
的点分为多个组(同属一个连通块的点属于一个组,一个组内的点都是连通的,不同组内的点都是不连通的)。
那么只需要对每个组,单独求一下好路径的数目即可。
假设这一组内有n
个值为x
的点,那么这一组内的好路径的节点对的数目就是 。(记得要加上全部值=x
的节点数量n
,因为单个节点同时作为起点和终点也构成一个好路径。)
对比我自己的思路,周赛当天,后面我想到将值相同的点放在一起考虑,并且用BFS双向搜索进行碰撞检测(其实就是对2个点之间连通性的检查),我这样是一种自顶向下的思路,即从一颗完整的树中,通过树的遍历来判断2个点是否连通;而另一种自底向上的做法是,初始时先将所有点都看成孤立的点,然后逐步将满足某些条件的点进行连通(将树进行还原),最后判断2个点的连通性,就可以用并查集非常简单的进行判断。然后还有一个点是,好路径内节点的值都是<=x
,那么可以先对所有节点,按照值从小到大排序,每次进行并查集合并时,只考虑所有<=x
即可。
另:根据这个思路,自己尝试写代码时,有个地方其实还是需要多考虑一下的。我们合并时是如何合并呢?是每次遇到一段连续值相同的区间,就合并一次;随后将并查集清零,下一次遇到一段连续值相同的区间,再进行一次合并吗?
其实不是。我们需要按照值从小到大依次对节点进行合并,比如在处理到连续值为x
的区间时,我们需要保证所有值<x
的那些节点,都已经被合并过了才行,就是此时需要保证所有值<x
的节点,都已经合并成了一个一个的连通块了。这样在处理值=x
的节点时,才能将其与所有<x
的点进行连通。
如下,假设有一段区间的值全为x
,后面有一段全为y
。在处理到y
时,如何保证前面已经合并了的那些点,是能够被y
正确使用的呢?由于我们每次合并,都是保留比当前值更小的,那么y
之前已经连通了的那些点,它们的值都是<=y
的,那么y
和前面任意的连通块进行连通(其实是和连通块当中的某一个点进行连通),都能保证y
所在的连通块中的点的值,都一定是<=y
的,此时好路径的2个条件都满足了,那么只需要判断2个值=y
的点之间是否连通即可。
然后就是对值=y
的区间内的点进行分组,看看同属一个组(同属一个连通块)的值为y
的点有多少个,然后套用上面的公式 ,就能算出这一组值为y
的点当中,能够形成的好路径的数目。再对每个分组进行一下累加。
// Java
class Solution {
int[] p;
private int find(int x) {
if (p[x] != x) p[x] = find(p[x]);
return p[x];
}
public int numberOfGoodPaths(int[] vals, int[][] edges) {
int n = vals.length;
List<Integer>[] lists = new List[n]; // 树的邻接表存储
// 建图
for (int[] e : edges) {
int a = e[0], b = e[1];
if (lists[a] == null) lists[a] = new ArrayList<>();
if (lists[b] == null) lists[b] = new ArrayList<>();
lists[a].add(b);
lists[b].add(a);
}
// 下标数组, 必须用Integer, 不能用int, 用int的API里不支持自定义的Comparator
Integer[] idx = new Integer[n];
for (int i = 0; i < n; i++) idx[i] = i;
Arrays.sort(idx, Comparator.comparingInt(o -> vals[o])); // 按照值将下标从小到大排序
// 初始化并查集
p = new int[n];
for (int i = 0; i < n; i++) p[i] = i;
int ans = 0;
// 按照值从小到大进行合并
for (int i = 0; i < n; i++) {
// 此时待合并的点的编号为 idx[i] , 取一下连续值相等的区间
int j = i + 1;
while (j < n && vals[idx[j]] == vals[idx[i]]) j++;
// 对这段区间内所有点所连通的点, 进行合并
for (int k = i; k < j; k++) {
int u = idx[k]; // 节点编号
if (lists[u] == null) continue; // 不存在任何邻接点
for (int t : lists[u]) {
// 处理每个邻接点, 若这个邻接点的值, <= vals[u] 的值, 才被合并进来
if (vals[t] <= vals[u]) p[find(t)] = find(u);
}
}
// 合并完成后, 对区间内的点进行一下分组, 根据所属连通块进行分组
Map<Integer, Integer> group = new HashMap<>();
for (int k = i ; k < j; k++) {
int g = find(idx[k]); // 获取这个节点编号所属的集合
group.put(g, group.getOrDefault(g, 0) + 1); // 计数+1
}
// 遍历所有组, 计算每组中的好路径数量
for (int v : group.values()) ans += v * (v + 1) / 2;
i = j - 1; // 修正一下 下一轮迭代的位置
}
return ans;
}
}
// C++
class Solution {
public:
vector<int> p;
int find(int x) {
if (x != p[x]) p[x] = find(p[x]);
return p[x];
}
int numberOfGoodPaths(vector<int>& vals, vector<vector<int>>& edges) {
int n = vals.size();
vector<int> idx(n); // 下标数组
p.resize(n);
for (int i = 0; i < n; i++) idx[i] = p[i] = i;
// 下标按照值从小到大进行排序
sort(idx.begin(), idx.end(), [&](int a, int b) {
return vals[a] < vals[b];
});
// 建图
vector<vector<int>> map(n);
for (auto &e : edges) {
int a = e[0], b = e[1];
map[a].push_back(b);
map[b].push_back(a);
}
// 并查集合并
int ans = 0;
for (int i = 0; i < n; i++) {
// 连续的值相同的区间
int j = i + 1;
while (j < n && vals[idx[j]] == vals[idx[i]]) j++;
// 对区间中每个点进行处理, 尝试将其与其邻接点进行连通
for (int k = i; k < j; k++) {
int u = idx[k]; // 这次要处理的点的编号
for (int v : map[u]) {
if (vals[v] <= vals[u]) p[find(v)] = find(u); // 合并
}
}
unordered_map<int, int> cnt;
// 进行分组统计
for (int k = i; k < j; k++) {
int u = idx[k];
cnt[find(u)]++; // 该点所属的连通块计数+1
}
for (auto &[k, v] : cnt) ans += v * (v + 1) / 2;
i = j - 1;
}
return ans;
}
};
总结
只打周赛感觉还不太够,得多刷题,图论这类题熟练度太低了。
另:
这次学到了一个将下标单独取出来,按照某种策略对下标进行排序的技巧。注意Java里面这样处理时,下标需要用Integer
数组来存,因为排序API(Arrays.sort
)对原始类型int
,不支持自定义的Comparator
。
另:
-
刷算法题目:锻炼代码能力,因为都是固定的题型,可以反复理解,记忆,练习;
-
参加周赛:锻炼思维能力,因为都不是原题。
一般刚开始,需要先锻炼代码能力,所以要先刷大量题目,第二个阶段需要锻炼思维能力,不仅要能把一道题目AC掉,还要能知道为什么这样做是正确的。
另:
Acwing的算法基础课和提高课,分类方法不是按照知识点来分的,而是按学习阶段来分的。同一个知识点,基础课讲的是代码模板和基础题型,提高课讲的是变形题和扩展题。所以可以发现,很多算法或知识点,在基础课当中有,提高课当中也有,但内容其实是不重复的。