LeetCode 312 周赛

92 阅读7分钟

6188. 按身高排序

给你一个字符串数组 names ,和一个由 互不相同 的正整数组成的数组 heights 。两个数组的长度均为 n

对于每个下标 inames[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) 运算得到的值 最大非空 子数组。

  • 换句话说,令 knums 任意 子数组执行按位与运算所能得到的最大值。那么,只需要考虑那些执行一次按位与运算后等于 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 个节点的树(连通无向无环的图),节点编号从 0n - 1 且恰好有 n - 1 条边。

给你一个长度为 n 下标从 0 开始的整数数组 vals ,分别表示每个节点的值。同时给你一个二维整数数组 edges ,其中 edges[i] = [ai, bi] 表示节点 aibi 之间有一条 无向 边。

一条 好路径 需要满足以下条件:

  1. 开始节点和结束节点的值 相同
  2. 开始节点和结束节点中间的所有节点值都 小于等于 开始节点的值(也就是说开始节点的值应该是路径上所有节点的最大值)。

请你返回不同好路径的数目。

注意,一条路径和它反向的路径算作 同一 路径。比方说, 0 -> 11 -> 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个,a1a2a3,那么可能的好路径的方案就可能有 C32=3C_3^2 = 3, 就是从3个中选2个点。那么更通用的来说,若值为x的点一共有n个,那么由值x构成的好路径的方案可能有 Cn2=n×(n1)2C_n^2 = \frac{n × (n - 1)}{2}

那么我们遍历全部可能的好路径,依次判断每个组合是否构成一个好路径(从起点和终点进行双向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++;
	}
}

这样的方法,在枚举所有好路径方案时,时间复杂度已经达到了 O(n2)O(n^2),对每一个好路径方案进行合法性判断时,又需要O(n)O(n)的复杂度。而这题n的最大值在10^4级别,则肯定会超时了。

当天我在最后2分钟进行了尝试提交,果不其然得到了TLE。

下面看看大佬的做法

------y总说:有点类似于树的分治问题,但是树的分治属于竞赛范畴,需要找树的重心,比较难,代码也很长,而力扣上的题目通常是面向面试。所以我们往树的遍历这些方向上去想。这道题的思路具有一定跳跃性,有点类似于求解最少生成树的Kruskal算法

此题类似于Acwing中的346题,事后可以加以练习。

我们可以将所有的点,按照值(vals),从小到大排序。那么值相同的点,一定在一段连续的区间内。假设这段区间内所有点的值都为x,那么这段区间内的两两点之间,满足了好路径的第一个条件,起点和终点的值相同;至于第二个条件,路径中的点的值都要小于等于两个端点。那么好路径中的点的值,都要<=x。则好路径中的点,只能从这段区间之前的点当中构成。那么,我们处理每个值=x的点,尝试将该点与其邻接点进行连通(只连接那些值<=x的邻接点)。那么对于好路径的第二个条件,我们只需要判断两个值为x的点,是否属于同一个连通块即可(这就可以用并查集来做)。

然后对于=x的这段区间,我们判断有多少对节点可以构成好路径,就要判断每一对节点之间的连通性。

若采用暴力枚举(共 Cn2C_n^2 中可能的点对),则复杂度是O(n2)O(n^2),由于采用了并查集,在我们将所有<=x的点全部连接好之后。我们可以将这些值=x的点分为多个组(同属一个连通块的点属于一个组,一个组内的点都是连通的,不同组内的点都是不连通的)。

那么只需要对每个组,单独求一下好路径的数目即可。

假设这一组内有n个值为x的点,那么这一组内的好路径的节点对的数目就是 Cn2+n=n×(n+1)2C_n^2 + n = \frac{n × (n + 1)}{2}。(记得要加上全部值=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的点有多少个,然后套用上面的公式 n×(n+1)2\frac{n × (n + 1)}{2},就能算出这一组值为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的算法基础课和提高课,分类方法不是按照知识点来分的,而是按学习阶段来分的。同一个知识点,基础课讲的是代码模板和基础题型,提高课讲的是变形题和扩展题。所以可以发现,很多算法或知识点,在基础课当中有,提高课当中也有,但内容其实是不重复的。