目录
岛屿的最大面积
这道题与基础算法题4中的“岛屿数量”问题在思路上基本一致。二者的核心差异在于:在“岛屿数量”问题中,每执行一次广度优先搜索(BFS),就将岛屿的数量加1,以此来统计岛屿的总数;而在本题中,每进行一次BFS操作,则是更新当前所遍历到的岛屿的最大面积。具体来说,在本题的BFS过程中,只需额外记录一下所遍历到的陆地单元格的数量,通过不断比较这些数量,就能确定并更新最大的岛屿面积。
class Solution {
// 定义方向数组,用于表示上下左右四个方向的偏移量
int[] dx = new int[]{0,0,-1,1};
int[] dy = new int[]{1,-1,0,0};
int m,n;
// 用于标记每个位置是否已经访问过的二维布尔数组
boolean[][] flag ;
public int maxAreaOfIsland(int[][] grid) {
m = grid.length;n = grid[0].length;
// 初始化标记数组 flag,大小为 m 行 n 列,所有元素初始值为 false,表示都未访问过
flag = new boolean[m][n];
int ret = 0;
for(int i=0;i<m;i++){
for(int j=0; j<n;j++){
// 如果当前位置未被访问过且值为 1(表示是陆地)
if(!flag[i][j] && grid[i][j] == 1){
// 标记当前位置已访问
flag[i][j] = true;
// 调用 bfs 方法计算当前岛屿的面积,并更新最大岛屿面积 ret
ret = Math.max(ret,bfs(grid,i,j));
}
}
}
return ret;
}
public int bfs(int[][] grid,int i,int j){
// 创建一个队列,用于广度优先搜索,队列中存储的是位置信息
Queue<int[]> q = new LinkedList<>();
q.offer(new int[]{i,j});
int ret = 0;
// 当队列不为空时,进行广度优先搜索
while(!q.isEmpty()){
int[] t = q.poll();
//只要可以入队的都是满足条件的 所以岛屿数量直接加1
ret++;
int a = t[0], b = t[1];
for(int k=0; k<4; k++){
int x = a+dx[k];
int y = b+dy[k];
// 如果相邻位置在二维数组范围内,未被访问过且值为 1(表示是陆地)
if(x>=0 && x<m && y>=0 && y<n &&!flag[x][y] && grid[x][y]==1){
flag[x][y] = true;
// 将相邻位置加入队列,以便继续搜索
q.offer(new int[]{x,y});
}
}
}
return ret;
}
}
单词接龙
127. 单词接龙 - 力扣(LeetCode)
和基础算法题4几乎是一样的,只是在原基础算法题4里,需要处理的字符集限定为四个特定字符。而如今,题目的要求出现了重要变化,字符集拓展为所有小写字符。这一改变意味着,在解题过程中,需对之前的算法逻辑进行调整。在遍历并改变基因序列时,要针对每一位的字符,将其循环替换为所有可能的小写字母即可。
class Solution {
public int ladderLength(String beginWord, String endWord, List<String> wordList) {
// 将单词列表转换为哈希集合以提高查找效率
Set<String> hash = new HashSet<>();
for(String t : wordList) {
hash.add(t);
}
// 记录已访问过的单词
Set<String> vis = new HashSet<>();
// 如果目标单词不在字典中,直接返回0
if(!hash.contains(endWord)) return 0;
Queue<String> q = new LinkedList<>();
q.offer(beginWord);
vis.add(beginWord);
// 初始化转换步数为1(包含起始单词)
int ret = 1;
while(!q.isEmpty()){
ret++; // 完成一层处理增加步数
int size = q.size();
// 处理当前层的所有节点
while(size-- != 0){
String s = q.poll();
char[] tmp = s.toCharArray();
// 变换当前单词的每一个字母
for(int i = 0; i < tmp.length; i++){
//记录当前字母,为了后续遍历完之后还原字符串
char t = tmp[i];
// 尝试用a-z替换当前字母
for(char j='a'; j <= 'z'; j++){
tmp[i] = j;
String newGene = new String(tmp);
// 检查变换后的单词是否有效
if(!vis.contains(newGene) && hash.contains(newGene)){
//如果变换后的单词等于目标单词直接返回
if(endWord.equals(newGene)) return ret;
// 将有效变换加入队列并标记为已访问
q.offer(newGene);
vis.add(newGene);
}
}
tmp[i] = t; //还原字符串
}
}
}
//遍历完以后没找到咋返回0
return 0;
}
}
地图中的最高点
在进行广度优先搜索(BFS)处理水域相关问题时,首先将所有水域的元素加入队列,并将其值初始化为0。同时,把非水域的元素标记为 -1,这既代表它们是陆地,也表明这些陆地尚未被访问。
完成初始化后,开始执行BFS算法。在搜索过程中,一旦遇到尚未访问过的元素,便依据该元素在扩展前所处位置的下标,将其值更新为扩展前元素值加1 。
class Solution {
//定义四个方向移动偏移量数组
int[] dx = new int[]{0,0,-1,1};
int[] dy = new int[]{1,-1,0,0};
public int[][] highestPeak(int[][] isWater) {
int m = isWater.length, n = isWater[0].length;
int[][] height = new int[m][n];
//初始化返回矩阵,并加水域高度0加入到队列
Queue<int[]> q = new LinkedList<>();
for(int i = 0; i < m; i++){
for(int j = 0; j < n; j++){
if(isWater[i][j] == 1){
height[i][j] = 0;
q.add(new int[]{i,j});
}else {
//-1表示为未访问过并且是陆地
height[i][j] = -1;
}
}
}
//BFS过程
while(!q.isEmpty()){
int[] t = q.poll();
int a = t[0], b = t[1];
//遍历当前下标处的上下左右四个位置
for(int i = 0; i < 4; i++){
int x = a + dx[i];
int y = b + dy[i];
//检查下标合法性,并未访问过
if(x>=0 && x<m && y>=0 && y<n && height[x][y] == -1){
//相邻陆地为当前高度+1
height[x][y] = height[a][b] + 1;
//将新下标加入到队列中,以便下一次扩展
q.add(new int[]{x,y});
}
}
}
return height;
}
}
火星词典
该题要读懂题,比较相邻的两个单词 words[i] 和 words[j](i < j),找到第一个不同的字符 c1 和 c2(c1 != c2)。这表示 c1 必须排在 c2 前面,即 c1 -> c2。
例如,["wrt", "wrf"] 中 't' -> 'f',因为 't' 和 'f' 是第一个不同的字符。
无效字典序的检测:如果 words[j] 是 words[i] 的前缀(如 "abc" 和 "ab"),则直接返回 "",因为字典序要求更长的单词必须排在更短的单词后面(如果前缀相同)。
使用拓扑排序进行解决,第一步先初始化化入度表都为0
第二步建图,比较所有单词对,找到第一个不同的字符并建立边,如果是新边则增加c2的入度之和,若 words[j] 是 words[i] 的前缀(如 "abc" 和 "ab"),直接返回 "";
第三步拓扑排序:
初始化队列:将所有入度为 0 的字符加入队列。
处理队列:取出字符 ch,加入结果 ret。
遍历 ch 的所有邻居 c,减少其入度;若入度为 0,加入队列
第四步检查环:若拓扑排序后仍有字符的入度不为 0,说明存在环,返回 ""。
注意存储边关系时使用Set集合是为了保证边不会重复,并且必须在有新边时才更新入度,而且比较不同时只找出第一个不同,不关心后续字符;
class Solution {
public String alienOrder(String[] words) {
// 构建图:edges 表示字符之间的边,例如 edges['a'] = {'b', 'c'} 表示 'a' -> 'b' 和 'a' -> 'c'
Map<Character, Set<Character>> edges = new HashMap<>();
// 存储每个字符的入度(即有多少字符指向它)
Map<Character, Integer> in = new HashMap<>();
// 初始化入度:遍历所有单词的所有字符,初始入度为 0
for (String s : words) {
for (char ch : s.toCharArray()) {
in.put(ch, 0);
}
}
// 建图:比较所有单词对,建立字符之间的顺序关系
for (int i = 0; i < words.length; i++) {
for (int j = i + 1; j < words.length; j++) {
String s1 = words[i]; // 前面的单词
String s2 = words[j]; // 后面的单词
int k = 0;
int n = Math.min(s1.length(), s2.length());
// 逐个字符比较,找到第一个不同的字符
for (; k < n; k++) {
char c1 = s1.charAt(k), c2 = s2.charAt(k);
if (c1 != c2) {
// 确保 c1 的边集合已初始化
if (!edges.containsKey(c1)) {
edges.put(c1, new HashSet<>());
}
// 如果 c1 -> c2 是新边(之前不存在),则增加 c2 的入度
if (edges.get(c1).add(c2)) {
in.put(c2, in.get(c2) + 1);
}
break; // 只需处理第一个不同的字符
}
}
// 如果 s2 是 s1 的前缀(如 "abc" 和 "ab"),则字典序无效,返回 ""
if (k == s2.length() && k < s1.length()) {
return "";
}
}
}
// 拓扑排序:将所有入度为 0 的字符加入队列
Queue<Character> q = new LinkedList<>();
for (char ch : in.keySet()) {
if (in.get(ch) == 0) {
q.offer(ch);
}
}
StringBuilder ret = new StringBuilder(); // 存储拓扑排序结果
while (!q.isEmpty()) {
char ch = q.poll();
ret.append(ch);
// 如果当前字符没有出边(即 edges 中没有它的记录),跳过
if (!edges.containsKey(ch)) {
continue;
}
// 遍历所有 ch 指向的字符,减少它们的入度
for (char c : edges.get(ch)) {
in.put(c, in.get(c) - 1);
// 如果入度减为 0,加入队列
if (in.get(c) == 0) {
q.offer(c);
}
}
}
// 检查是否有环:如果仍有字符的入度不为 0,说明存在环,返回 ""
for (char ch : in.keySet()) {
if (in.get(ch) != 0) {
return "";
}
}
return ret.toString();
}
}
有效三角形的个数
如果暴力解法就是把所以可能全都列出来,但是决定会超时,针对暴力解法进行优化:
1.暴力解法时,以判断三角形三边关系为例,传统暴力解法是分别比较三次: (a + b > c)、(a + c > b) 以及 (b + c > a)。然而,若对数组进行排序,仅需判断一次,即比较两个较小值的和与最大值的大小关系。这是因为在有序数组中,最大值必然大于任何一个较小值,无需再进行额外比较。
2.对于枚举所有可能情况的暴力解法,优化思路同样基于排序。在排序后,从最大值开始从后向前遍历数组。此时,定义两个指针,left 指向数组开头,right指向当前最大值的前一个位置。当 left 和 right 所指元素之和大于当前最大值时,说明在 left 到 right 这个区间内的所有组合都满足条件。因为若 left 继续向后移动,其与 right 的和只会更大,必然也大于最大值。此时,只需计算这个区间的元素个数,然后将 right向左移动一位继续判断。
当 left 和 right 所指元素之和小于当前最大值时,应将 left`向右移动。这是因为只有这样,left 和 right的和才会逐渐增大,以便继续寻找满足条件的组合。
class Solution {
public int triangleNumber(int[] nums) {
int count = 0, len = nums.length;
Arrays.sort(nums); // 先对数组排序,方便后续双指针操作
// 从后往前遍历,固定最大的边 nums[i]
for(int i = len-1; i > 1; i--){
for(int left=0,right=i-1; left<right; ){
// 如果 nums[left] + nums[right] > nums[i],说明:
// 1. nums[left], nums[right], nums[i] 可以构成三角形
// 2. 由于数组已排序,left 到 right-1 的所有元素与 nums[right]、nums[i] 也能构成三角
if(nums[left] + nums[right] > nums[i]){
count += right-left;
right--; // 尝试更小的 nums[right]
} else {
// 如果 nums[left] + nums[right] <= nums[i],说明 nums[left] 太小,
// 需要增大 nums[left](左指针右移)
left++;
}
}
}
return count;
}
}
将x减到0的最小操作数
1658. 将 x 减到 0 的最小操作数 - 力扣(LeetCode)
按照题目原本的要求直接求解,难度极大。因此,不妨转换思路,采用逆向思维的方法。题目原本是要在左区间找到一个区间加上右区间,使它们元素相加等于x,这种做法困难重重。但从图中看出,除了a和b所在的部分,中间的区间是连续的,并且这个连续区间所有值的总和恰好等于整个区间的总和减去x。
如此一来,问题便转化为找出最长子数组长度,他们的和等于数组总和 sum 减去目标值 x ,而这一问题可以借助滑动窗口算法加以解决。
需要留意的是,题目要求的是最少操作数。在求解总和为 sum - x 的连续区域时,我们实际上要找的是最长的这样一个连续区域,因为只有连续区域最长,左右两侧用于调整的 a 和 b 的数量才会最少。另外,当数组总和小于目标值 x 时,可直接返回结果,因为在这种情况下,无论如何操作都无法使数组总和减少至 x 。
class Solution {
public int minOperations(int[] nums, int x) {
int count = -1; // 记录满足条件的子数组的最大长度(初始化为-1表示未找到)
int len = nums.length;
int sum1 = 0;
for(int i = 0; i < len; i++) sum1 += nums[i]; //求出数组元素和
// 特殊情况处理:如果x大于总和,直接返回-1(因为无法通过操作使总和减少到x)
if(x > sum1) return -1;
int sum2 = 0;
// 滑动窗口算法:通过双指针left和right维护窗口
for(int left=0,right=0; right < len;right++){
// 右指针向右移动,扩大窗口(增加当前和)
sum2 += nums[right];
// 当窗口内和超过目标值(sum1 - x)时,左指针右移缩小窗口
while(sum2 > sum1 - x){
sum2-=nums[left++]; // 减去左边界元素并移动左指针
}
// 如果窗口内和正好等于目标值,更新最大窗口长度
if(sum2 == sum1 - x){
count =Math.max(count,right-left+1);
}
}
// 返回结果:若找到有效子数组,则总长度减去子数组长度即为最小操作数;否则返回-1
return count == -1 ? -1 : len-count;
}
}
X的平方根
从暴力解法分析,能够发现这道题具有二段性,因此可以直接运用二分查找的思路来解题。具体操作是,持续取中间值进行相乘运算。当运算结果小于或等于目标值时,把左端点更新为当前中间值,这里不能跳过中间值,因为答案可能是向上取整的,当前中间值有可能就是我们要找的结果。而当运算结果大于目标值时,就直接跳过该中间值。此外,要特别注意使用 long类型进行计算,因为使用 int 类型可能会导致溢出问题。
class Solution {
public int mySqrt(int x) {
long left = 0,right = x;
while(left < right){
// 计算中间值(+1避免死循环,例如x=1时left=0, right=1的情况)
// 注意:这里(left + right + 1)/2 等效于向上取整,保证区间收缩方向正确
long mid = left + (right - left + 1) /2 ;
if(mid <= x/mid) left = mid;
else right = mid - 1;
}
return (int)left;
}
}
除自身以外数组的乘积
238. 除自身以外数组的乘积 - 力扣(LeetCode)
这道题与基础算法题 4 中寻找数组的中心下标时采用的两个前缀和解法极为相似。不同之处在于,本题需要处理边界情况。由于通过new
操作创建的元素初始值都为 0,因此必须将前缀积数组中第一个元素(下标为 0)之前的积初始化为 1,而不能是 0,否则后续计算结果都将为 0。同理,后缀积数组中最后一个元素也要进行类似处理。最后,将每个下标位置对应的前缀积和后缀积相乘,并将结果存入返回数组中,完成合并操作。
class Solution {
public int[] productExceptSelf(int[] nums) {
int len = nums.length;
// 前缀积数组:prev[i] 表示 nums[0] × nums[1] × ... × nums[i-1]
int[] f = new int[len];
// 后缀积数组:latter[i] 表示 nums[i+1] × nums[i+2] × ... × nums[len-1]
int[] g = new int[len];
// 初始化边界值
f[0] = g[len-1] = 1;
// 预处理:计算前缀积(从左到右)
for(int i = 1; i < len; i++){
f[i] = nums[i-1] * f[i - 1]; // 递推公式:prev[i] = prev[i-1] × nums[i-1]
}
// 预处理:计算后缀积(从右到左)
for(int i = len-2; i >= 0; i--){
g[i] = nums[i + 1] * g[i + 1]; // 递推公式:latter[i] = latter[i+1] × nums[i+1]
}
int[] ret = new int[len];
// 合并结果:ret[i] = 前缀积 × 后缀积
for(int i=0; i < len; i++){
ret[i] = f[i] * g[i];
}
return ret;
}
}
只出现一次的数字II
137. 只出现一次的数字 II - 力扣(LeetCode)
利用位运算统计每一位出现1的次数,因为其他数字都出现三次,所以数组每个元素每位的次数总和模3后,剩下的就是唯一数字的比特位;
这里无需纠结 n 的具体数值,因为我们并不需要确切知晓数组中每个元素在第 i 个比特位上出现的次数。即便有两个元素(每个元素有三个相同的,即总共 6 个元素)在第 i 比特位上同时出现,它们的出现次数也无关紧要,因为对 3 取模后结果为 0。我们唯一需要关注的,是仅出现 1 次的那个数字在第 i 比特位上的值。这个唯一出现一次的数字在该比特位的值必然等于总次数对 3 取模的结果,无论这个结果是 0 还是 1。具体而言:
- 当总次数 % 3 = 0 时,唯一数字在该比特位的值为 0;
- 当总次数 % 3 = 1 时,唯一数字在该比特位的值为 1。
关键的认知突破点在于:无需计算每个元素的具体出现次数,仅需明确总次数与 3 的余数关系。这一过程就如同使用筛子,对 3 取模的操作会自动滤除所有重复三次的数字。
class Solution {
public int singleNumber(int[] nums) {
int ret = 0;
//统计nums数组中所有元素每一位比特位的和
for(int i = 0; i< 32; i++){
int sum = 0; // 统计当前位(第i位)上所有数字的1的个数
// 遍历数组中的每个数字
for(int j = 0; j < nums.length; j++){
// 检查当前数字的第i位是否为1 ,如果结果 != 0 表示第i位是1,则计算器sum+1
if((nums[j] & (1 << i) )!= 0 ) sum++;
}
// 对于出现三次的数字,它们的每一位的和应该是3的倍数
// 所以sum % 3得到的就是只出现一次的数字在当前位的值(0或1)
// 将这个值左移i位,然后与ret进行或运算,设置ret的对应位
ret |= (sum % 3) << i;
}
return ret;
}
}
外观数列
这道题属于模拟类型的题目。在初始化返回值时,直接将其设为 "1",这是由于序列的第一项必定是 "1"。后续只需进行 n - 1 次解释操作,因此从 1 开始进行模拟。若只需生成第一列(即 n = 1 的情况),直接返回初始的 "1" 即可。
接下来,定义两个双指针用于遍历待解释的字符串。当遇到与前一个字符不同的字符,或者超出待解释字符串的范围时,便可以更新结果字符串。需要注意的是,更新时应先追加字符出现的计数,再追加字符本身。同时,不要忘记更新 left 指针的位置,left 指针始终指向当前正在解释的字符下标。每完成一次解释,都需要将临时生成的解释字符串拷贝回返回字符串,这是因为在解释过程中,临时字符串可能会覆盖掉原本返回字符串中的部分内容。
class Solution {
public String countAndSay(int n) {
// 初始字符串为"1",这是外观数列的第一项
String s = "1";
// 解释n-1次来得到第n项
// 例如:n=1直接返回"1",n=2需要解释一次,n=3需要解释两次,以此类推
for(int i = 1; i < n; i++) {
StringBuilder tmp = new StringBuilder();
// 使用双指针技术遍历当前字符串
// left指向当前字符组的起始位置,right用于扩展查找相同字符
for(int left = 0, right = 0; right < s.length();) {
// 扩展right指针,直到找到不同的字符或到达字符串末尾
while(right < s.length() && s.charAt(left) == s.charAt(right)) right++;
// 将计数和字符添加到新字符串中
// right-left计算相同字符的数量
tmp.append("" + (right - left)); // 添加计数
tmp.append(s.charAt(left)); // 添加字符本身
// 移动left指针到下一组字符的起始位置
left = right;
}
// 更新s为新的解释结果,准备下一次迭代
s = tmp.toString();
}
return s;
}
}