3.31
笔试真题2/5
皇后攻击位置
国际象棋比赛上,行数是数字1-8,列数是字母a到h,比如第二行第四列就是d2
现在在某位置放置一个皇后的话,皇后能攻击到的位置有哪些(皇后攻击直线与斜线)
输入:例如‘a1’;输出:先输出攻击到的位置总数,然后输出所有可以攻击到的位置
-
对输入的行列处理成数组,使用字符相减的形式
-
设置
List<String> res
来存储结果 -
皇后可攻击位置 同行 但排除自己;
-
皇后可攻击位置 同列 但排除自己;
-
皇后可攻击位置 斜线范围加减7以内的数 不越界 且不包括加0
-
输出位置的时候要转换成国际象棋行列格式,列要加'a',行要加‘1’
package 笔试.1; import java.util.ArrayList; import java.util.List; import java.util.Scanner; import java.util.*; /** * 国际象棋比赛上,行数是数字1-8,列数是字母a到h,比如第二行第四列就是d2 * 现在在某位置放置一个皇后的话,皇后能攻击到的位置有哪些(皇后攻击直线与斜线) * 输入:例如‘a1’;输出:先输出攻击到的位置总数,然后输出所有可以攻击到的位置 * * 1. 对输入的行列处理成数组,使用字符相减的形式 * 2. 设置 `List<String> res` 来存储结果 * 3. 皇后可攻击位置 同行 但排除自己; * 4. 皇后可攻击位置 同列 但排除自己; * 5. 皇后可攻击位置 斜线范围加减7以内的数 不越界 且不包括加0 * 6. 输出位置的时候要转换成国际象棋行列格式,列要加'a',行要加‘1’ */ public class 皇后可以攻击的位置 { public static void main(String[] args) { Scanner in = new Scanner(System.in); while (in.hasNextLine()){ String inStr = in.nextLine(); getPosition(inStr); } } private static void getPosition(String inStr){ // * 1. 对输入的行列处理成数组,使用字符相减的形式 int col = inStr.charAt(0) - 'a'; int row = inStr.charAt(1) - '1'; // * 2. 设置 `List<String> res` 来存储结果 List<String> res = new ArrayList<>(); // * 3. 皇后可攻击位置 同行 row 但排除自己;同行不同列 for(int i = 0; i < 8; i++){ if(col != i){ // * 6. 输出位置的时候要转换成国际象棋行列格式,列要加'a',行要加‘1’ res.add((char)(i + 'a') + "" + (row + 1)); } } // * 4. 皇后可攻击位置 同列 但排除自己; for(int i = 0; i < 8; i++){ if(row != i){ res.add((char)(col + 'a') + "" + (i + 1)); } } // * 5. 皇后可攻击位置 斜线范围加减7以内的数 不越界 且不包括加0 for(int i = -7; i <= 7 ; i++){ if(i != 0){ int newCol = col + i; int newRow = row + i; if(newRow <= 7 && newCol <= 7 && newRow >= 0 && newCol >= 0){ res.add((char)(newCol + 'a') + "" + (newRow + 1)); } } } System.out.println(res.size()); int count = 0; for(String s: res){ System.out.print(s); while (count < res.size() - 1){ System.out.print(" "); count++; break; } } System.out.println(); } }
注意细节
-
注意
res.add((char)('a' + newCol) + "" + (newRow + 1));
的(char)
不要忘记 -
注意
res.add((char)(newCol + 'a') + "" + (newRow + 1));
的newRow + 1
是数字,不要写成+‘1’
拓展:Knight攻击到King
国际象棋是一种双人策略棋类游戏,每位玩家控制一套共16件的棋子,各种棋子的移动方式不同,代表不同的角色,它们包括:
-
国王(King):每方有1个。国王的移动非常重要,但也非常受限,每次只能在任何方向(横、竖、斜)移动一格。游戏的目标是将军对方国王,使对方国王无法逃脱。
-
皇后(Queen):每方有1个。皇后是棋盘上最强大的棋子,可以横、竖、斜任何方向移动,但不能跳过其他棋子。
-
车(Rook):每方有2个。车移动时只能沿着棋盘的横线或竖线移动,不可以斜行,但移动的距离没有限制。
-
象(Bishop):每方有2个。象只能沿斜线移动,移动距离没有限制,但不能跳过其他棋子。
-
马,骑士(Knight):每方有2个。马的移动方式独特,它移动时是“L”形的(即先直行两格,再横行一格,或者先横行两格,再直行一格),并且是唯一可以跳过其他棋子的棋子。
-
兵(Pawn):每方有8个。兵只能直行前进,首次移动可以选择前进一格或两格,之后每次只能前进一格,并且只能斜向前进攻击对方的棋子。
package 笔试.1; import java.util.LinkedList; import java.util.Queue; public class 拓展_Knight可以攻击的位置 { // 国际象棋棋盘的大小,标准棋盘是8x8 private static final int SIZE = 8; // 骑士的8个可能移动方向。每个方向是一个二元组(行变化, 列变化) // 例如,-2行和-1列表示骑士可以向上两格然后向左一格 private static final int[][] directions = { {-2, -1}, {-1, -2}, {1, -2}, {2, -1}, {-2, 1}, {-1, 2}, {1, 2}, {2, 1} }; /** * 使用广度优先搜索(BFS)算法计算骑士从当前位置到国王位置所需的最少步数。 * * @param knightPos 骑士的当前位置,格式为"e4"(列+行) * @param kingPos 国王的位置,格式相同 * @return 骑士到国王位置所需的最少步数 */ public static int minStepsToKing(String knightPos, String kingPos) { // 解析骑士和国王的位置,转换成0到7的索引 int knightCol = knightPos.charAt(0) - 'a'; int knightRow = knightPos.charAt(1) - '1'; int kingCol = kingPos.charAt(0) - 'a'; int kingRow = kingPos.charAt(1) - '1'; // 如果骑士已经在国王的位置,直接返回0步 if (knightCol == kingCol && knightRow == kingRow) { return 0; } // visited数组用于标记棋盘上的每个位置是否已经访问过 boolean[][] visited = new boolean[SIZE][SIZE]; // 使用队列进行BFS搜索。队列中的每个元素包含当前位置和到达该位置的步数 Queue<int[]> queue = new LinkedList<>(); // 将骑士的起始位置以及起始步数0加入队列 queue.offer(new int[]{knightRow, knightCol, 0}); while (!queue.isEmpty()) { int[] current = queue.poll(); // 取出队列的当前元素 int row = current[0]; // 当前行 int col = current[1]; // 当前列 int steps = current[2]; // 到达当前位置的步数 // 遍历骑士的所有可能移动方向 for (int[] direction : directions) { int newRow = row + direction[0]; // 计算新的行位置 int newCol = col + direction[1]; // 计算新的列位置 // 检查是否到达了国王的位置 if (newRow == kingRow && newCol == kingCol) { return steps + 1; // 返回到达国王位置的步数 } // 检查新位置是否在棋盘内且未被访问过 if (newRow >= 0 && newRow < SIZE && newCol >= 0 && newCol < SIZE && !visited[newRow][newCol]) { visited[newRow][newCol] = true; // 标记为已访问 // 将新位置和步数加1加入队列 queue.offer(new int[]{newRow, newCol, steps + 1}); } } } // 如果算法正确,理论上不可能到达这里,因为在棋盘内总能找到到国王的路径 return -1; } public static void main(String[] args) { String knightPos = "d4"; // 骑士的起始位置 String kingPos = "f7"; // 国王的位置 System.out.println("Minimum steps: " + minStepsToKing(knightPos, kingPos)); } }
奇偶各半的最小操作
package 笔试.1; import java.util.*; /** * 小红有一个长度为偶数的数组,她每次操作可以将一个数字乘 2 或将一个数字除以 2 向下取整。(向下取整等同于直接 /2 * 小红想知道她至少需要操作几次才能把这个数组变成一半是奇数,一半是偶数。 */ public class Main2 { public static void main(String[] args) { Scanner in = new Scanner(System.in); // 使用循环来处理多个输入案例 while (in.hasNextInt()) { // 循环直到没有更多的整数输入 int n = in.nextInt(); // 读取数组的长度 int[] nums = new int[n]; // 创建一个长度为n的数组 int evenCount = 0; // 用于计数数组中偶数的数量 int oddCount = 0; // 用于计数数组中奇数的数量 // 遍历输入的每个数字,填充数组并统计奇数和偶数的数量 for(int i = 0; i < n; i++){ nums[i] = in.nextInt(); if(nums[i] % 2 == 0) evenCount++; else oddCount++; } int count = 0; // 用于记录操作的次数 // 循环直到奇数和偶数的数量相等 while(evenCount != oddCount){ if(evenCount > oddCount){ // 如果偶数比奇数多,找到最小的偶数并将其除以2 int minEven = Integer.MAX_VALUE; // 注意:这里应初始化为MAX_VALUE而不是MIN_VALUE for(int i = 0; i < n; i++){ // 寻找最小的偶数 if(nums[i] % 2 == 0 && nums[i] < minEven) minEven = nums[i]; } for(int i = 0; i < n; i++){ // 将找到的最小偶数除以2,从而将其转换为奇数 if(nums[i] == minEven){ nums[i] /= 2; break; // 只对一个数字操作即可 } } evenCount--; oddCount++; }else{ // 如果奇数比偶数多,找到最大的奇数并将其乘以2(如果乘以2后不超过10^9) int maxOdd = 0; for(int i = 0; i < n; i++){ if(nums[i] % 2 != 0 && nums[i] > maxOdd && nums[i] * 2 <= Math.pow(10, 9)){ maxOdd = nums[i]; } } for(int i = 0; i < n; i++){ if(nums[i] == maxOdd){ nums[i] *= 2; // 将找到的最大奇数乘以2,从而将其转换为偶数 break; // 只对一个数字操作即可 } } oddCount--; evenCount++; } count++; // 操作次数加1 } System.out.println(count); // 打印最少操作次数 } } }
这个问题的策略反映了尝试以最少的操作次数达成目标(数组一半是奇数,一半是偶数)的逻辑。让我们分析为什么这种策略(找到最大的奇数乘以2以及找到最小的偶数除以2)是有效的:
找到最小的偶数并除以2:
偶数除以2可能得到奇数或偶数,但如果原偶数是最小的且大于1,则除以2后必然得到的是一个更小的偶数或一个奇数。如果目的是减少偶数的数量,使其等于奇数的数量,那么将最小的偶数转变为奇数是最直接的方法。
这种方法优先选择最小的偶数,是因为较小的数字在除以2时到达1的速度更快,这意味着较少的操作。此外,较小的偶数在除以2后变为奇数,对数组的其他元素影响最小。
找到最大的奇数并乘以2:
乘以2总是产生偶数,这是将奇数转换为偶数的直接方法。选择最大的奇数进行操作,是因为我们希望通过尽可能少的步骤达成目标。较大的奇数乘以2后超过数组中其他数字的可能性较小(在不超过109109的前提下),这样可以避免对达成最终数组状态的其他数字产生不必要的影响。
这种策略尽量保持数组的“平衡”状态,避免了大规模的数值增长,有助于更快地达到目标状态。
为什么这样做效率高:
操作次数最小化:通过选择最小的偶数和最大的奇数进行操作,我们可以在每一步中直接将一个数字从奇数转换为偶数或反之,而不需要额外的步骤。
避免过大数值:选择最大的奇数乘以2而不是任意奇数,是为了避免创建过大的数字,这可能导致需要更多步骤才能将该数字转换回目标状态需要的奇数或偶数。同时,对于最小的偶数除以2,是为了快速减少偶数数量,同时尽可能维持数组数值的稳定。
注意细节
-
防止溢出
nums[i] * 2 <= Math.pow(10, 9)
-
防止溢出:在编程中,所有数值类型(如int、long等)都有一个最大值。超过这个最大值可能会导致溢出,进而产生不可预测的结果。虽然在Java中,int类型的最大值是
Integer.MAX_VALUE
,即231−1231−1(大约21亿),远大于109109,但限制在109109是为了符合题目或实际场景的约束,保证算法的适用性和稳定性。 -
避免不必要的大数运算:大数运算会消耗更多的计算资源和时间。在解题或实际应用中,通常希望算法尽可能高效。通过限制数值范围,可以避免因处理过大的数而降低程序的效率。
-
满足题目要求:很多算法题目会特意给出数值范围的限制,要求算法在这个范围内有效运行。这样的限制不仅是为了确保算法的有效性,也是为了检验算法设计者是否注意到并能处理边界条件和特殊情况。在这个问题中,如果某个操作会导致数字超过109109,那么这个操作可能不是一个合理的选择,因为它可能不满足题目要求或在实际应用中引发问题。
-
-
for(int i = 0; i < n; i++){ // 将找到的最小偶数除以2,从而将其转换为奇数 if(nums[i] == minEven){ nums[i] /= 2; break; // 只对一个数字操作即可 } }
这里的break是停止当前的for循环:这里的
break
使得程序停止执行当前的for
循环(标记为寻找并修改最小偶数的循环),不再继续查找或修改其他元素。此时,控制流将移动到for
循环之后的第一条语句,继续执行调整evenCount
和oddCount
的操作,然后是while
循环的下一次迭代或while
循环的结束(如果已满足终止条件evenCount != oddCount
)。
链表
删除链表的倒数第N个节点【复习】
代码随想录 (programmercarl.com)
给你一个链表,删除链表的倒数第 n 个结点,并且返回链表的头结点。
双指针:双指针的经典应用,如果要删除倒数第n个节点,让fast移动n步,然后让fast和slow同时移动,直到fast指向链表末尾。删掉slow所指向的节点就可以了。
注意最后要返回的时 dummy.next
而不直接是 head
class Solution { public ListNode removeNthFromEnd(ListNode head, int n) { ListNode dummy = new ListNode(-1); dummy.next = head; ListNode fast = dummy; ListNode slow = dummy; for(int i = 0; i <= n; i++){ // 快指针先向后移动 n+1 步,这样一起移动时,当 fast 到 null,slow 就在倒数第 n 个节点的前一个节点了 fast = fast.next; } while(fast != null){ slow = slow.next; fast = fast.next; } // 要删除slow.next slow.next = slow.next.next; return dummy.next; } }
HJ48 从单向链表中删除指定值的节点
如何从键盘输入构造链表
-
定义链表节点类
class ListNode { int val; ListNode next; ListNode(int val) { this.val = val; this.next = null; } }
-
从键盘输入构建链表
使用Scanner
类读取用户的输入(假设用户将输入一个数字序列,每个数字代表链表中的一个节点的值,输入完成后输入一个非数字标识,如end
来结束输入)。
import java.util.Scanner; public class Main { public static void main(String[] args) { Scanner scanner = new Scanner(System.in); // 假定链表的头节点为null,表示开始时链表为空 ListNode head = null; ListNode tail = null; System.out.println("Enter numbers to add to the list, type 'end' to finish:"); // 循环读取输入直到用户输入"end" while (scanner.hasNextInt()) { int val = scanner.nextInt(); ListNode newNode = new ListNode(val); // 如果链表为空,则新节点既是头节点也是尾节点 if (head == null) { head = newNode; tail = newNode; } else { // 否则,添加到尾部,并更新尾节点 tail.next = newNode; tail = newNode; } } // 打印构建的链表 System.out.println("The constructed list is: "); printList(head); scanner.close(); // 关闭scanner对象 } // 辅助方法:打印链表 public static void printList(ListNode node) { while (node != null) { System.out.print(node.val + " "); node = node.next; } } }
处理输入
输入描述:
输入一行,有以下4个部分:
1 输入链表结点个数 2 输入头结点的值 3 按照格式插入各个结点 4 输入要删除的结点的值
输出描述:
输出一行
输出删除结点后的序列,每个数后都要加空格
这个格式包含了节点值和前驱节点的值
static class ListNode{ int val; ListNode next; ListNode(int val){ this.val = val; this.next = null; } }
int n = in.nextInt(); int headVal = in.nextInt(); Map<Integer, ListNode> nodeMap = new HashMap<>(); ListNode head = new ListNode(headVal); nodeMap.put(headVal, head); for(int i = 1; i < n; i++){ int curVal = in.nextInt(); int preVal = in.nextInt(); ListNode newNode = new ListNode(curVal); nodeMap.put(curVal, newNode); ListNode preNode = nodeMap.get(preVal); newNode.next = preNode.next; preNode.next = newNode; }
package hw.HJ; import java.util.*; /** * 输入一个单向链表和一个节点的值,从单向链表中删除等于该值的节点,删除后如果链表中无节点则返回空指针。 * 链表的值不能重复。 */ public class HJ48_从单向链表中删除指定值的节点 { static class ListNode{ int val; ListNode next; ListNode(int val){ this.val = val; this.next = null; } } public static void main(String[] args) { /** * **输入描述:** * 输入一行,有以下4个部分: * 1 输入链表结点个数 * 2 输入头结点的值 * 3 按照格式插入各个结点 * 4 输入要删除的结点的值 * **输出描述:** * 输出一行 * 输出删除结点后的序列,每个数后都要加空格 * 输入:5 2 3 2 4 3 5 2 1 4 3 * 输出:2 5 4 1 * 说明:形成的链表为2->5->3->4->1;删掉节点3,返回的就是2->5->4->1 */ Scanner in = new Scanner(System.in); while (in.hasNextInt()){ int n = in.nextInt(); int headVal = in.nextInt(); Map<Integer, ListNode> nodeMap = new HashMap<>(); ListNode head = new ListNode(headVal); nodeMap.put(headVal, head); for(int i = 1; i < n; i++){ int curVal = in.nextInt(); int preVal = in.nextInt(); ListNode newNode = new ListNode(curVal); nodeMap.put(curVal, newNode); ListNode preNode = nodeMap.get(preVal); newNode.next = preNode.next; preNode.next = newNode; } int toDeleteVal = in.nextInt(); // 处理链表,删除某个节点等于特定数值的节点,再返回链表头节点 head = deleteNode(head, toDeleteVal); // 注意最后返回的要是dummy.next ListNode cur = head; while (cur != null){ System.out.print(cur.val + " "); cur = cur.next; } System.out.println(); } in.close(); } private static ListNode deleteNode(ListNode head, int toDeleteVal){ ListNode dummy = new ListNode(-1); dummy.next = head; ListNode curNode = dummy; while (curNode.next != null){ if(curNode.next.val == toDeleteVal){ // 如果有节点的值等于要删除的值,就删除该节点 curNode.next = curNode.next.next; } curNode = curNode.next; } return dummy.next; } }
注意细节
-
if (n == 0) break; // 如果没有节点则结束
-
注意定义节点类时静态
遇到
'hw.HJ.HJ48_从单向链表中删除指定值的节点.this' cannot be referenced from a static context
这个错误是因为在静态方法main
中试图访问一个非静态的内部类ListNode
。在Java中,静态上下文(如静态方法)不能直接访问所属类的实例成员和方法,因为静态上下文不依赖于任何特定对象实例来运行。要解决这个问题,有两个常见的方法:
-
将
ListNode
类定义为静态的:这样,ListNode
就可以在静态上下文中被访问了,因为静态的类或成员属于类本身,而不是类的某个特定实例。 -
在一个非静态上下文中使用
ListNode
:这通常意味着你需要创建一个外部类的实例来访问内部类,但在main
方法(一个静态上下文)中通常不采用这种方式。
-
-
构造链表时
Map<Integer, ListNode> nodeMap = new HashMap<>();
来存储节点的 值 与 节点 -
头节点后面添加节点的数量是
n-1
,即for(int i = 1; i < n; i++){
新建当前节点 前一个节点,用
当前节点.next = 前一个节点.next
,然后前一个节点.next = 当前节点
int curVal = in.nextInt();
int preVal = in.nextInt();
ListNode newNode = new ListNode(curVal);
nodeMap.put(curVal, newNode);
ListNode preNode = nodeMap.get(preVal);
newNode.next = preNode.next;
preNode.next = newNode;
单调栈
下一个更大元素I
nums1
中数字 x
的 下一个更大元素 是指 x
在 nums2
中对应位置 右侧 的 第一个 比 x
大的元素。
示例 1:
输入:nums1 = [4,1,2], nums2 = [1,3,4,2]. 输出:[-1,3,-1] 解释:nums1 中每个值的下一个更大元素如下所述: - 4 ,用加粗斜体标识,nums2 = [1,3,4,2]。不存在下一个更大元素,所以答案是 -1 。 - 1 ,用加粗斜体标识,nums2 = [1,3,4,2]。下一个更大元素是 3 。 - 2 ,用加粗斜体标识,nums2 = [1,3,4,2]。不存在下一个更大元素,所以答案是 -1 。
结合哈希表1
class Solution { public int[] nextGreaterElement(int[] nums1, int[] nums2) { // 用于存储nums2中每个元素及其下一个更大元素的映射 Map<Integer, Integer> map = new HashMap<>(); // 使用双端队列作为栈,用来临时存储还未找到下一个更大元素的元素索引 Deque<Integer> dq = new LinkedList<>(); // 遍历nums2数组,为数组中每个元素寻找其下一个更大元素 for (int i = 0; i < nums2.length; i++) { // 当栈不为空且栈顶元素小于当前元素时,说明找到了栈顶元素的下一个更大元素 while (!dq.isEmpty() && nums2[dq.peek()] < nums2[i]) { // 将栈顶元素对应的下一个更大元素记录在map中 map.put(nums2[dq.peek()], nums2[i]); // 弹出栈顶元素,因为已经找到了它的下一个更大元素 dq.pop(); } // 将当前元素的索引压入栈中,等待找到其下一个更大元素 dq.push(i); } // 初始化结果数组,长度与nums1相同,初始值设为-1(表示未找到下一个更大元素) int[] res = new int[nums1.length]; Arrays.fill(res, -1); // 遍历nums1数组,利用map中的映射查找每个元素的下一个更大元素 for (int i = 0; i < nums1.length; i++) { // 如果nums1中的元素在map中有记录,则更新结果数组中对应位置的值 res[i] = map.getOrDefault(nums1[i], -1); } return res; // 返回结果数组 } }
结合哈希表2
class Solution { public int[] nextGreaterElement(int[] nums1, int[] nums2) { // 使用HashMap存储nums1中每个元素的索引,便于在找到更大元素时快速定位到结果数组的对应位置 HashMap<Integer, Integer> map = new HashMap<>(); for (int i = 0; i < nums1.length; i++) { map.put(nums1[i], i); } // 初始化结果数组,长度与nums1相同,并默认填充-1,表示如果没有找到更大元素,则保持为-1 int[] res = new int[nums1.length]; Arrays.fill(res, -1); // 使用栈存储nums2的索引,栈顶元素始终为当前考虑的“较小元素”的索引 Stack<Integer> stack = new Stack<>(); // 遍历nums2,目的是为nums1中的每个元素找到其在nums2中的下一个更大元素 for (int i = 0; i < nums2.length; i++) { // 当栈不为空且当前元素大于栈顶元素对应的值时,说明找到了某个或某些元素的下一个更大元素 while (!stack.isEmpty() && nums2[stack.peek()] < nums2[i]) { // 弹出栈顶元素索引,并获取该元素 int pre = nums2[stack.pop()]; // 如果该元素在nums1中(即我们关心的元素),则更新结果数组 if (map.containsKey(pre)) { res[map.get(pre)] = nums2[i]; } } // 将当前元素的索引压入栈中,表示这是当前正在考虑的较小元素 stack.push(i); } return res; // 返回存储有下一个更大元素的结果数组 } }
下一个更大元素II
给定一个循环数组 nums
( nums[nums.length - 1]
的下一个元素是 nums[0]
),返回 nums
中每个元素的 下一个更大元素 。
数字 x
的 下一个更大的元素 是按数组遍历顺序,这个数字之后的第一个比它更大的数,这意味着你应该循环地搜索它的下一个更大的数。如果不存在,则输出 -1
。
输入: nums = [1,2,1] 输出: [2,-1,2] 解释: 第一个 1 的下一个更大的数是 2; 数字 2 找不到下一个更大的数; 第二个 1 的下一个最大的数需要循环搜索,结果也是 2。
将两个nums数组拼接在一起,使用单调栈计算出每一个元素的下一个最大值,最后再把结果集即result数组resize到原数组大小就可以了。
class Solution { public int[] nextGreaterElements(int[] nums) { Deque<Integer> dq = new LinkedList<>(); int[] nums2 = new int[nums.length * 2]; for(int i = 0; i < nums.length; i++){ nums2[i] = nums[i]; nums2[i + nums.length] = nums[i]; } int[] res = new int[nums.length]; Arrays.fill(res, -1); for (int i = 0; i < nums2.length; i++) { // 当栈不为空且栈顶元素小于当前元素时,说明找到了栈顶元素的下一个更大元素 while (!dq.isEmpty() && nums2[dq.peek()] < nums2[i]) { if(dq.peek() < nums.length){ res[dq.peek()] = nums2[i]; } // 弹出栈顶元素,因为已经找到了它的下一个更大元素 dq.pop(); } // 将当前元素的索引压入栈中,等待找到其下一个更大元素 dq.push(i); } return res; } }
善用 i % size
:
class Solution { public int[] nextGreaterElements(int[] nums) { //边界判断 if(nums == null || nums.length <= 1) { return new int[]{-1}; } int size = nums.length; int[] result = new int[size];//存放结果 Arrays.fill(result,-1);//默认全部初始化为-1 Stack<Integer> st= new Stack<>();//栈中存放的是nums中的元素下标 for(int i = 0; i < 2*size; i++) { while(!st.empty() && nums[i % size] > nums[st.peek()]) { result[st.peek()] = nums[i % size];//更新result st.pop();//弹出栈顶 } st.push(i % size); } return result; } }
4.2
贪心算法
分发糖果
n
个孩子站成一排。给你一个整数数组 ratings
表示每个孩子的评分。
你需要按照以下要求,给这些孩子分发糖果:
-
每个孩子至少分配到
1
个糖果。 -
相邻两个孩子评分更高的孩子会获得更多的糖果。
请你给每个孩子分发糖果,计算并返回需要准备的 最少糖果数目 。
这道题目一定是要确定一边之后,再确定另一边,例如比较每一个孩子的左边,然后再比较右边,如果两边一起考虑一定会顾此失彼。
先确定右边评分大于左边的情况(也就是从前向后遍历)
此时局部最优:只要右边评分比左边大,右边的孩子就多一个糖果,全局最优:相邻的孩子中,评分高的右孩子获得比左边孩子更多的糖果
局部最优可以推出全局最优。
所以确定左孩子大于右孩子的情况一定要从后向前遍历!
如果 ratings[i] > ratings[i + 1],此时candyVec[i](第i个小孩的糖果数量)就有两个选择了,一个是candyVec[i + 1] + 1(从右边这个加1得到的糖果数量),一个是candyVec[i](之前比较右孩子大于左孩子得到的糖果数量)。
那么又要贪心了,局部最优:取candyVec[i + 1] + 1 和 candyVec[i] 最大的糖果数量,保证第i个小孩的糖果数量既大于左边的也大于右边的。全局最优:相邻的孩子中,评分高的孩子获得更多的糖果。
局部最优可以推出全局最优。
所以就取candyVec[i + 1] + 1 和 candyVec[i] 最大的糖果数量,candyVec[i]只有取最大的才能既保持对左边candyVec[i - 1]的糖果多,也比右边candyVec[i + 1]的糖果多。
用了两次贪心的策略:
-
一次是从左到右遍历,只比较右边孩子评分比左边大的情况。
-
一次是从右到左遍历,只比较左边孩子评分比右边大的情况。
这样从局部最优推出了全局最优,即:相邻的孩子中,评分高的孩子获得更多的糖果。
public int candy(int[] ratings) { // 获取评分数组的长度 int len = ratings.length; // 初始化一个数组来存储每个孩子的糖果数,所有孩子先给一颗糖 int[] candyVec = new int[len]; candyVec[0] = 1; // 第一个孩子至少有一颗糖 // 第一阶段:从左向右遍历评分数组 // 如果一个孩子的评分比他左边的孩子高,那么他应该比左边的孩子多一颗糖 for (int i = 1; i < len; i++) { candyVec[i] = (ratings[i] > ratings[i - 1]) ? candyVec[i - 1] + 1 : 1; } // 第二阶段:从右向左遍历评分数组 // 如果一个孩子的评分比他右边的孩子高,那么他的糖果数应为: // 当前存储的糖果数和右边孩子糖果数+1之间的最大值 for (int i = len - 2; i >= 0; i--) { if (ratings[i] > ratings[i + 1]) { candyVec[i] = Math.max(candyVec[i], candyVec[i + 1] + 1); } } // 计算总糖果数,将每个孩子的糖果数累加起来 int ans = 0; for (int num : candyVec) { ans += num; } // 返回需要的最少糖果数 return ans; }
注意细节
-
两次贪心,第一次从左向右,第二次从右向左;注意从右向左时,如果左面比右边的大则取 右边加一 以及 当前值 中的 最大值
-
注意
a = (b > c) ? b : c;
的写法
柠檬水找零
在柠檬水摊上,每一杯柠檬水的售价为 5 美元。
顾客排队购买你的产品,(按账单 bills 支付的顺序)一次购买一杯。
每位顾客只买一杯柠檬水,然后向你付 5 美元、10 美元或 20 美元。你必须给每个顾客正确找零,也就是说净交易是每位顾客向你支付 5 美元。
注意,一开始你手头没有任何零钱。
如果你能给每位顾客正确找零,返回 true ,否则返回 false 。
示例 1:
输入:[5,5,5,10,20]
输出:true
解释:
前 3 位顾客那里,我们按顺序收取 3 张 5 美元的钞票。
第 4 位顾客那里,我们收取一张 10 美元的钞票,并返还 5 美元。
第 5 位顾客那里,我们找还一张 10 美元的钞票和一张 5 美元的钞票。
由于所有客户都得到了正确的找零,所以我们输出 true。
class Solution { public boolean lemonadeChange(int[] bills) { /** 只需要出理 5 10 20 三种情况 count5 count10 count20 */ int count5 = 0; int count10 = 0; for(int i = 0; i < bills.length; i++){ if(bills[i] == 5){ count5++; } if(bills[i] == 10){ count10++; count5--; } if(bills[i] == 20){ if(count10 > 0){ count10--; count5--; }else{ count5 -= 3; } } if(count10 < 0 || count5 < 0){ return false; } } return true; } }
4.6
回溯算法
void backtracking(参数) { if (终止条件) { 存放结果; return; } for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) { 处理节点; backtracking(路径,选择列表); // 递归 回溯,撤销处理结果 } }
子集
给定一组不含重复元素的整数数组 nums,返回该数组所有可能的子集(幂集)。
说明:解集不能包含重复的子集。
示例: 输入: nums = [1,2,3] 输出: [ [3], [1], [2], [1,2,3], [1,3], [2,3], [1,2], [] ]
如果把 子集问题、组合问题、分割问题都抽象为一棵树的话,那么组合问题和分割问题都是收集树的叶子节点,而子集问题是找树的所有节点!
其实子集也是一种组合问题,因为它的集合是无序的,子集{1,2} 和 子集{2,1}是一样的。
那么既然是无序,取过的元素不会重复取,写回溯算法的时候,for就要从startIndex开始,而不是从0开始!
有同学问了,什么时候for可以从0开始呢?
求排列问题的时候,就要从0开始,因为集合是有序的,{1, 2} 和{2, 1}是两个集合,排列问题我们后续的文章就会讲到的。
求取子集问题,不需要任何剪枝!因为子集就是要遍历整棵树。
要清楚子集问题和组合问题、分割问题的的区别,子集是收集树形结构中树的所有节点的结果。
而组合问题、分割问题是收集树形结构中叶子节点的结果。
class Solution { List<List<Integer>> res = new ArrayList<>(); LinkedList<Integer> path = new LinkedList<>(); public List<List<Integer>> subsets(int[] nums) { backtrack(nums, 0); return res; } private void backtrack(int[] nums, int startIndex){ res.add(new ArrayList<>(path)); if(startIndex >= nums.length){ return; } for(int i = startIndex; i < nums.length; i++){ path.add(nums[i]); backtrack(nums, i + 1); path.removeLast(); } } }
不使用LinkedList,速度提升
class Solution { List<Integer> temp = new ArrayList<>(); List<List<Integer>> res = new ArrayList<>(); public List<List<Integer>> subsets(int[] nums) { dfs(nums, 0); return res; } void dfs(int[] nums, int x) { res.add(new ArrayList<>(temp)); for (int i = x; i < nums.length; i++) { temp.add(nums[i]); dfs(nums, i + 1); temp.remove(temp.size() - 1); } } }
子集II
理解“树层去重”和“树枝去重”非常重要
注意去重需要先对集合排序
使用used
理解
!used[i - 1]
这个条件确实有点反直觉。在处理去重问题时,我们可能会本能地认为,如果前一个相同的元素
used[i - 1]
被使用过(即为true
),这才应该跳过当前元素,因为它会产生重复的子集。然而,在这个特定的算法设计中,!used[i - 1]
(即used[i - 1]
为false
)起到了关键的去重作用。为什么使用
!used[i - 1]
这里的关键在于理解算法是如何处理重复元素的,特别是它是如何确保当出现重复元素时,仅在合适的场合才将这些元素加入子集中,以此来避免重复子集的生成。考虑以下两种情况:
used[i - 1]
为true
(即前一个相同的元素被使用过): 这种情况通常发生在递归的深层中,当我们已经选择了前一个元素,并且正在考虑是否选择当前重复的元素。由于前一个元素被选中,选择当前元素不会造成重复的子集(因为我们是在构建一个包含这个重复元素的新子集)。
!used[i - 1]
为true
(即前一个相同的元素没有被使用过): 这种情况意味着我们之前跳过了一个重复元素,并且现在又遇到了一个相同的元素。如果此时我们选择加入当前元素,就会产生与之前跳过的元素相同的子集,因此产生了重复。实际作用
当
!used[i - 1]
为true
时(即前一个相同元素未被使用),说明我们刚刚回溯过,并且在当前的路径中已经考虑了包括前一个元素在内的所有情况。此时,如果再考虑当前元素,就会和之前不选择前一个元素但选择当前元素的情况重复,因此需要跳过以避免重复。因此,
!used[i - 1]
的使用是一种精妙的设计,它利用了回溯算法的特性和对搜索空间的精确控制来避免生成重复的子集。这种设计巧妙地利用了used
数组的状态来区分何时一个元素的选择会导致重复的子集的生成,并据此跳过这些情况。
class Solution { List<List<Integer>> res = new ArrayList<>(); // 存储最终结果的列表 LinkedList<Integer> path = new LinkedList<>(); // 存储当前路径的链表 boolean[] used; // 用于标记元素是否被使用过 public List<List<Integer>> subsetsWithDup(int[] nums) { Arrays.sort(nums); // 对数组进行排序,以便处理重复元素 used = new boolean[nums.length]; // 初始化used数组 backtrack(nums, 0); // 调用回溯函数,从索引0开始 return res; // 返回结果 } private void backtrack(int[] nums, int startIndex){ res.add(new ArrayList<>(path)); // 将当前路径添加到结果列表中 for(int i = startIndex; i < nums.length; i++){ // 如果当前元素与前一个元素相同,并且前一个元素未被使用,则跳过当前循环 if(i > 0 && nums[i] == nums[i - 1] && !used[i - 1]){ continue; } path.add(nums[i]); // 将当前元素加入路径 used[i] = true; // 标记当前元素已被使用 backtrack(nums, i + 1); // 递归调用,继续向下搜索 path.removeLast(); // 回溯,移除最后一个元素 used[i] = false; // 恢复当前元素的未使用状态 } } }
不使用used【理解不通】
class Solution { List<List<Integer>> res = new ArrayList<>(); // 存储最终结果的列表 LinkedList<Integer> path = new LinkedList<>(); // 存储当前路径的链表 // 主函数,输入一个整型数组,返回所有可能的子集 public List<List<Integer>> subsetsWithDup(int[] nums) { Arrays.sort(nums); // 对数组进行排序,以处理重复元素 backtrack(nums, 0); // 调用回溯函数,从索引0开始 return res; // 返回结果 } // 回溯函数,用于生成所有可能的子集 private void backtrack(int[] nums, int startIndex) { res.add(new ArrayList<>(path)); // 将当前路径加入结果列表 // 遍历数组,从startIndex开始 for(int i = startIndex; i < nums.length; i++) { // 如果当前元素与前一个元素相同且不是起始元素,则跳过,以避免重复 if(i > startIndex && nums[i] == nums[i - 1]) { continue; } path.add(nums[i]); // 将当前元素加入路径 backtrack(nums, i + 1); // 递归调用,继续向下搜索 path.removeLast(); // 回溯,移除最后一个元素 } } }
想象你在一个巨大的图书馆里寻找书籍,这个图书馆的书是按照类别和书名字母顺序排列的。你的任务是找到所有可能的不同类别和书名的组合,但是你要避免重复拿起相同的书。这里的“类别”相当于数组中的每个不同的数字,而书名的字母顺序则对应于数字的排列顺序。
进入不同的房间(递归层级)
每次你选择一本书(或者决定不选任何书)然后进入一个新的房间,这个新房间里有所有剩余的书。这相当于在回溯算法中深入一个新的递归层级。
startIndex
就是你在当前房间中开始寻找书的位置。避免重复选择
现在,假设你在一个房间里找到了三本完全相同的书。你第一次看到这样的书时,会考虑拿起它(加入到你的“选择列表”中),因为它可能是你想要的组合的一部分。但是,当你拿起第一本书后,再遇到第二本和第三本完全相同的书时,你就会意识到,如果你再拿它们,就会重复之前已经考虑过的组合。因此,除非你是在探索新的组合,否则你会跳过这些完全相同的书。
为什么是
i > startIndex
?回到我们的图书馆比喻,
i > startIndex
实际上意味着,“只有当我在当前房间(即当前递归层级)里向前移动,并且发现我手里的这本书与我刚刚放下的那本书完全一样时,我才会决定不再拿起这本书。” 这样做是为了确保当你在探索新的组合时不会漏探任何可能,同时也避免了重复选择相同的书造成的组合重复。如果我们使用
i > 0
作为条件,那就相当于说,只要我不是第一次选择书(无论我现在在哪个房间里),我就要检查手里的这本书是不是和之前的一样,这样就可能错误地跳过了某些应该被考虑的新组合。结论
通过使用
i > startIndex
,我们确保了在每个“房间”(递归层级)内部正确地识别和处理重复书籍(元素),而不会影响到从一个房间到另一个房间的过渡。这样,我们既探索了所有可能的组合,又避免了重复,就像在图书馆里有效地挑选不同组合的书籍一样。
当处理数组
[1, 2, 2]
并且希望生成所有不重复的子集时,使用回溯法和去重逻辑可以按以下流程操作:1. 初始化和排序
首先,由于数组已经是排序状态
[1, 2, 2]
,我们可以直接开始。2. 开始回溯
我们从一个空子集开始,逐步添加元素直到无法添加为止,然后回溯尝试其他选择。使用
i > startIndex && nums[i] == nums[i - 1]
来避免重复。3. 回溯流程详解
- 空子集
开始时,我们有一个空子集
[]
,它是所有子集的基础。- 添加
1
首先添加1,得到子集 [1]。
接下来,尝试添加剩余的元素2和2。由于它们是重复的,我们会在每个新层级中只考虑第一个出现的2,直到这一层级的可能性被完全探索。
添加第一个2得到 [1, 2]。
然后尝试添加下一个
2
,得到[1, 2, 2]
。回溯到
[1, 2]
,但下一个2
由于与前一个2
相同且按照我们的规则,我们不会再次单独选择它,因为i > startIndex && nums[i] == nums[i - 1]
阻止了这个操作。- 回溯到
1
完成所有以
[1]
开始的子集探索后,我们回溯到空子集[]
,然后尝试下一个元素。- 添加第一个
2
现在,我们直接添加第一个2(由于1已经被考虑过了),得到 [2]。
尝试添加下一个
2
,得到[2, 2]
。再次,我们遵循规则,避免了在这一层级中重复添加相同的元素。
- 完成
最终,我们已经探索了所有可能的子集,包括
[]
,[1]
,[2]
,[1, 2]
,[1, 2, 2]
,[2, 2]
。每次回溯的时候,我们都遵循了去重规则,避免了生成重复的子集。关键点
重点在于理解,当我们遇到重复元素时,我们只在这个元素第一次出现时(在每一层级的第一个位置时)考虑它。这保证了我们不会错过任何可能的组合,同时也避免了生成重复的子集。通过回溯到之前的选择点(
startIndex
),并且每次只考虑新的或未被重复考虑的元素,我们可以有效地生成所有唯一的子集。
初始准备
首先,数组被排序,变为
[1, 2, 2]
。初始化的子集为
[]
。开始回溯过程
回溯过程从
startIndex = 0
开始。
初始状态:
添加初始空路径(子集)
[]
到结果中。当前结果集:
[[]]
第一层递归 - 添加
1
:
对于
i = 0
,添加1
到路径中,路径更新为[1]
。递归调用
backtrack(nums, 1)
,进入下一层。当前结果集:
[[], [1]]
第二层递归 - 基于
[1]
,尝试添加2
:
由于
startIndex = 1
,首先添加[1, 2]
到结果中。递归调用
backtrack(nums, 2)
,进入下一层。当前结果集:
[[], [1], [1, 2]]
第三层递归 - 基于
[1, 2]
,尝试添加第二个2
:
由于
startIndex = 2
,此时尝试添加第二个2
,路径更新为[1, 2, 2]
。添加
[1, 2, 2]
到结果中。回溯,移除路径中最后一个元素,路径回到
[1, 2]
。当前结果集:
[[], [1], [1, 2], [1, 2, 2]]
回溯到第二层,继续尝试添加第二个
2
,但因为去重规则跳过:
回溯到
[1]
。对于第二个
2
,由于i = 2
,此时i > startIndex
,且nums[i] == nums[i - 1]
成立,因此跳过不添加,避免重复。当前结果集不变。
第一层递归 - 添加第一个
2
:
回溯到初始路径
[]
,尝试直接添加第一个2
。添加
[2]
到结果中,然后递归调用backtrack(nums, 2)
。当前结果集:
[[], [1], [1, 2], [1, 2, 2], [2]]
尝试添加第二个
2
,形成[2, 2]
:
添加
[2, 2]
到结果中。当前结果集:
[[], [1], [1, 2], [1, 2, 2], [2], [2, 2]]
去重的关键步骤:
在尝试将第二个
2
添加到[1]
时,i > startIndex
且nums[i] == nums[i - 1]
规则确保了我们跳过了直接从[1]
尝试添加第二个2
的步骤。这一规则有效地防止了[1, 2]
被重复添加进结果集。
4.7
贪心算法
根据身高重建队列
假设有打乱顺序的一群人站成一个队列,数组 people 表示队列中一些人的属性(不一定按顺序)。每个 people[i] = [hi, ki] 表示第 i 个人的身高为 hi ,前面 正好 有 ki 个身高大于或等于 hi 的人。
请你重新构造并返回输入数组 people 所表示的队列。返回的队列应该格式化为数组 queue ,其中 queue[j] = [hj, kj] 是队列中第 j 个人的属性(queue[0] 是排在队列前面的人)。
示例 1:
输入:people = [[7,0],[4,4],[7,1],[5,0],[6,1],[5,2]]
输出:[[5,0],[7,0],[5,2],[6,1],[4,4],[7,1]]
解释:
编号为 0 的人身高为 5 ,没有身高更高或者相同的人排在他前面。
编号为 1 的人身高为 7 ,没有身高更高或者相同的人排在他前面。
编号为 2 的人身高为 5 ,有 2 个身高更高或者相同的人排在他前面,即编号为 0 和 1 的人。
编号为 3 的人身高为 6 ,有 1 个身高更高或者相同的人排在他前面,即编号为 1 的人。
编号为 4 的人身高为 4 ,有 4 个身高更高或者相同的人排在他前面,即编号为 0、1、2、3 的人。
编号为 5 的人身高为 7 ,有 1 个身高更高或者相同的人排在他前面,即编号为 1 的人。
因此 [[5,0],[7,0],[5,2],[6,1],[4,4],[7,1]] 是重新构造后的队列。
示例 2:
输入:people = [[6,0],[5,0],[4,0],[3,2],[2,2],[1,4]]
输出:[[4,0],[5,0],[2,2],[3,2],[1,4],[6,0]]
提示:
1 <= people.length <= 2000
0 <= hi <= 10^6
0 <= ki < people.length
题目数据确保队列可以被重建
自己的想法【显然不行】
// 从前往后遍历, start = 0;++;当遇到 在前面,比 peoplestart 身高高或者相同的数量,与实际不同
// 如果 peoplestart 比当前统计的n要多的话,就向后移动 peoplestart - n 步
// 如果 peoplestart 比当前统计的n要少的话,就像前移动 n - peoplestart 步
贪心
如果两个维度一起考虑一定会顾此失彼。
对于本题相信大家困惑的点是先确定k还是先确定h呢,也就是究竟先按h排序呢,还是先按照k排序呢?
如果按照k来从小到大排序,排完之后,会发现k的排列并不符合条件,身高也不符合条件,两个维度哪一个都没确定下来。
那么按照身高h来排序呢,身高一定是从大到小排(身高相同的话则k小的站前面),让高个子在前面。
此时我们可以确定一个维度了,就是身高,前面的节点一定都比本节点高!
按照身高排序之后,优先按身高高的people的k来插入,后序插入节点也不会影响前面已经插入的节点,最终按照k的规则完成了队列。
所以在按照身高从大到小排序后:
局部最优:优先按身高高的people的k来插入。插入操作过后的people满足队列属性
全局最优:最后都做完插入操作,整个队列满足题目队列属性
局部最优可推出全局最优,找不出反例,那就试试贪心。
排序完的people: [[7,0], [7,1], [6,1], [5,0], [5,2],[4,4]]
插入的过程:
-
插入[7,0]:[[7,0]]
-
插入[7,1]:[[7,0],[7,1]]
-
插入[6,1]:[[7,0],[6,1],[7,1]]
-
插入[5,0]:[[5,0],[7,0],[6,1],[7,1]]
-
插入[5,2]:[[5,0],[7,0],[5,2],[6,1],[7,1]]
-
插入[4,4]:[[5,0],[7,0],[5,2],[6,1],[4,4],[7,1]]
此时就按照题目的要求完成了重新排列。
class Solution { public int[][] reconstructQueue(int[][] people) { // 身高倒着排,从前往后按索引插 Arrays.sort(people, (a,b) -> { if(b[0] == a[0]) return a[1] - b[1]; return b[0] - a[0]; }); LinkedList<int[]> queue = new LinkedList<>(); for(int[] p : people){ queue.add(p[1], p); } return queue.toArray(new int[people.length][]); } }
注意细节
-
思想是,以身高维度考虑,降序排序,这样前面的就一定比后面的高或者相同;
这样就可以贪心,后面的直接放到对应的索引上,例如[[5,0],[7,0],[6,1],[7,1]],插入[5,2],前面两个肯定比5大或者相同:[[5,0],[7,0],[5,2],[6,1],[7,1]]。这也不会影响后面的顺序,因为从大到小插入,不影响后面的比当前身高大的部分的 ‘他们的顺序’
-
其技巧都是确定一边然后贪心另一边,两边一起考虑,就会顾此失彼
用最少数量的箭引爆气球
在二维空间中有许多球形的气球。对于每个气球,提供的输入是水平方向上,气球直径的开始和结束坐标。由于它是水平的,所以纵坐标并不重要,因此只要知道开始和结束的横坐标就足够了。开始坐标总是小于结束坐标。
一支弓箭可以沿着 x 轴从不同点完全垂直地射出。在坐标 x 处射出一支箭,若有一个气球的直径的开始和结束坐标为 xstart,xend, 且满足 xstart ≤ x ≤ xend,则该气球会被引爆。可以射出的弓箭的数量没有限制。 弓箭一旦被射出之后,可以无限地前进。我们想找到使得所有气球全部被引爆,所需的弓箭的最小数量。
为了让气球尽可能的重叠,需要对数组进行排序。既然按照起始位置排序,那么就从前向后遍历气球数组,靠左尽可能让气球重复。
从前向后遍历遇到重叠的气球了怎么办?
如果气球重叠了,重叠气球中右边边界的最小值 之前的区间一定需要一个弓箭。
/** * 时间复杂度 : O(NlogN) 排序需要 O(NlogN) 的复杂度 * 空间复杂度 : O(logN) java所使用的内置函数用的是快速排序需要 logN 的空间 */ class Solution { public int findMinArrowShots(int[][] points) { // 根据气球直径的开始坐标从小到大排序 // 使用Integer内置比较方法,不会溢出 Arrays.sort(points, (a, b) -> Integer.compare(a[0], b[0])); int count = 1; // points 不为空至少需要一支箭 for (int i = 1; i < points.length; i++) { if (points[i][0] > points[i - 1][1]) { // 气球i和气球i-1不挨着,注意这里不是>= count++; // 需要一支箭 } else { // 气球i和气球i-1挨着 points[i][1] = Math.min(points[i][1], points[i - 1][1]); // 更新重叠气球最小右边界 } } return count; } }
注意细节
-
// 使用Integer内置比较方法,不会溢出 Arrays.sort(points, (a, b) -> Integer.compare(a[0], b[0]));
Integer.compare()
方法:这个方法是Integer
类的一个静态方法,用于比较两个int
值。它接受两个参数,并返回三个可能的值:-
如果第一个参数小于第二个,返回负数;
-
如果两个参数相等,返回0;
-
如果第一个参数大于第二个,返回正数。
避免溢出:使用
Integer.compare(a[0], b[0])
而不是直接使用减法(如a[0] - b[0]
)的原因之一是避免在计算时发生整数溢出。当处理非常大或非常小的整数时,直接相减可能会导致结果超出int
类型的表示范围,进而产生错误的比较结果。Integer.compare()
通过比较两个整数的大小而不直接进行数学运算,有效避免了这种溢出问题。 -
-
在
for
循环中,对排序后的气球进行遍历,关键逻辑在于如何处理重叠的气球:-
不重叠情况:如果当前气球
i
的开始坐标大于前一个气球i-1
的结束坐标,意味着这两个气球不重叠,因此需要另外一支箭来射击当前的气球,即count++
。 -
重叠情况:如果当前气球
i
与前一个气球i-1
重叠(即,当前气球的开始坐标不大于前一个气球的结束坐标),则我们需要更新当前考虑的重叠区域的右边界,以使得这个区域尽可能小。这是因为,缩小重叠区域可以让后续的箭更有可能同时射穿更多的气球。
这里的关键操作是:
points[i][1] = Math.min(points[i][1], points[i - 1][1]);
这行代码更新了重叠气球最小右边界的原因和逻辑如下:
-
在重叠的气球中,为了使一支箭射穿尽可能多的气球,我们需要在它们共同重叠的区域里射箭。这个共同重叠的区域至少从当前气球的开始坐标开始。
-
更新当前气球的结束坐标为它与前一个气球结束坐标的较小值是为了保持重叠区域的准确性。这样,随着遍历的进行,每次遇到重叠的气球,我们都缩小了考虑的重叠区域的范围,保证了这个区域是所有已经遍历过的、并且与当前气球重叠的气球共同重叠的区域。
-
通过不断更新重叠区域的右边界,我们可以保证,只要下一个气球的开始坐标在这个更新后的重叠区域内,就可以用同一支箭射穿它。这种方法保证了用最少的箭射穿所有重叠的气球。
-
-
重叠的就更新边界,不重叠的直接射
-
保证每个都被射,并且尽可能重叠
4.8
动态规划
使用最小花费爬楼梯
数组的每个下标作为一个阶梯,第 i 个阶梯对应着一个非负数的体力花费值 cost[i](下标从 0 开始)。
每当你爬上一个阶梯你都要花费对应的体力值,一旦支付了相应的体力值,你就可以选择向上爬一个阶梯或者爬两个阶梯。
请你找出达到楼层顶部的最低花费。在开始时,你可以选择从下标为 0 或 1 的元素作为初始阶梯。
示例 1:
输入:cost = [10, 15, 20]
输出:15
解释:最低花费是从 cost[1] 开始,然后走两步即可到阶梯顶,一共花费 15 。
示例 2:
输入:cost = [1, 100, 1, 1, 1, 100, 1, 1, 100, 1]
输出:6
解释:最低花费方式是从 cost[0] 开始,逐个经过那些 1 ,跳过 cost[3] ,一共花费 6 。
提示:
cost 的长度范围是 [2, 1000]。
cost[i] 将会是一个整型数据,范围为 [0, 999] 。
-
确定dp数组以及下标的含义
使用动态规划,就要有一个数组来记录状态,本题只需要一个一维数组dp[i]就可以了。
dp[i]的定义:到达第i台阶所花费的最少体力为dp[i]。
对于dp数组的定义,大家一定要清晰!
-
确定递推公式
可以有两个途径得到dp[i],一个是dp[i-1] 一个是dp[i-2]。
dp[i - 1] 跳到 dp[i] 需要花费 dp[i - 1] + cost[i - 1]。
dp[i - 2] 跳到 dp[i] 需要花费 dp[i - 2] + cost[i - 2]。
那么究竟是选从dp[i - 1]跳还是从dp[i - 2]跳呢?
一定是选最小的,所以dp[i] = min(dp[i - 1] + cost[i - 1], dp[i - 2] + cost[i - 2]);
-
dp数组如何初始化
看一下递归公式,dp[i]由dp[i - 1],dp[i - 2]推出,既然初始化所有的dp[i]是不可能的,那么只初始化dp[0]和dp[1]就够了,其他的最终都是dp[0]dp[1]推出。
那么 dp[0] 应该是多少呢? 根据dp数组的定义,到达第0台阶所花费的最小体力为dp[0],那么有同学可能想,那dp[0] 应该是 cost[0],例如 cost = [1, 100, 1, 1, 1, 100, 1, 1, 100, 1] 的话,dp[0] 就是 cost[0] 应该是1。
-
确定遍历顺序
最后一步,递归公式有了,初始化有了,如何遍历呢?
本题的遍历顺序其实比较简单,简单到很多同学都忽略了思考这一步直接就把代码写出来了。
因为是模拟台阶,而且dp[i]由dp[i-1]dp[i-2]推出,所以是从前到后遍历cost数组就可以了。
但是稍稍有点难度的动态规划,其遍历顺序并不容易确定下来。 例如:01背包,都知道两个for循环,一个for遍历物品嵌套一个for遍历背包容量,那么为什么不是一个for遍历背包容量嵌套一个for遍历物品呢? 以及在使用一维dp数组的时候遍历背包容量为什么要倒序呢?
-
举例推导dp数组
拿示例2:cost = [1, 100, 1, 1, 1, 100, 1, 1, 100, 1] ,来模拟一下dp数组的状态变化,如下:
如果大家代码写出来有问题,就把dp数组打印出来,看看和如上推导的是不是一样的。
第一步不支付费用
class Solution { public int minCostClimbingStairs(int[] cost) { // 初始化一个长度为cost数组长度加1的dp数组,dp[i]表示达到第i步所需的最小成本 int[] dp = new int[cost.length + 1]; // 初始条件,dp[0]和dp[1]都为0,因为起始点可以是楼梯的底部(不需要成本) // 或者第一步(同样不需要额外成本,因为成本是在离开那一步时支付的) dp[0] = dp[1] = 0; // 从第二步开始遍历到楼梯顶("楼梯之上") for(int i = 2; i <= cost.length; i++){ // 对于每一步,计算两种方式到达那里的最小成本: // 1. 从前一步上来,此时的总成本是到达前一步的成本加上前一步的支付成本 // 2. 从前两步上来,此时的总成本是到达前两步的成本加上前两步的支付成本 // 使用Math.min选择两者中的较小值作为到达当前步骤的最小成本 dp[i] = Math.min(dp[i-1] + cost[i-1], dp[i-2] + cost[i-2]); } // 返回到达楼梯顶部(位于数组末尾之后的“楼梯之上”位置)的最小成本 // 也就是dp数组的最后一个元素 return dp[cost.length]; } }
第一步支付费用
class Solution { public int minCostClimbingStairs(int[] cost) { // 初始化一个长度为cost.length的dp数组,dp[i]表示到达第i步时已支付的总成本 int[] dp = new int[cost.length]; // 初始化条件,到达第一步和第二步的最小成本就是踏上这一步的成本本身 dp[0] = cost[0]; dp[1] = cost[1]; // 从第三步开始,计算到达每一步的最小总成本 for (int i = 2; i < cost.length; i++) { // 到达第i步的最小成本是从前一步或前两步上来的最小成本加上踏上当前步的成本 dp[i] = Math.min(dp[i - 1], dp[i - 2]) + cost[i]; } // 考虑到达楼梯顶部的最后一步可以从最后一步或倒数第二步上来 // 此时,我们不需要支付踏上“楼梯之上”位置的成本,因为题目允许在最后一步或倒数第二步结束 // 因此,最小成本是最后两个dp元素的最小值,代表的是到达最后一步或倒数第二步的最小总成本 return Math.min(dp[cost.length - 1], dp[cost.length - 2]); } }
这种方法的核心思想是“支付方式”的改变。在这种情况下,你在每一步踏上楼梯时就立即支付那一步的成本,而不是在离开那一步时支付。这意味着:
dp[i]
现在表示到达并踏上第i
步时,你已经支付的成本总和。对于每一步
i
(从2开始,因为第0步和第1步的成本是预先定义的),你都有两种选择:要么从i-1
步上来,要么从i-2
步上来。你会选择这两种方式中成本更小的一种,然后加上踏上当前步i
的成本。当你达到数组的末尾时,你实际上有两个选择来完成爬楼梯:要么从倒数第二步直接到达顶部,要么先到达最后一步,然后再到达顶部。在这两种情况下,你都不需要为“踏上楼梯之上”的动作支付成本。因此,最后的最小成本是
dp
数组中最后两个元素的最小值。
注意细节
-
为什么
dp
数组要是cost.length+1
在这个问题中,你要找的是达到楼梯顶部的最小成本,其中
cost
数组给出了每一步的成本。你可以从楼梯的第一步或第二步开始,每次可以爬一步或两步,并可以选择在一步之后停止,即达到“楼梯之上”的位置,这意味着实际的目标位置是在给定的cost
数组长度之外。这个动态规划(DP)解决方案的思路是创建一个
dp
数组,其中dp[i]
表示到达第i
步的最小成本。但为了覆盖全部情况,包括起始位置和结束位置(即“楼梯之上”的位置),dp
数组的长度被设置为cost.length + 1
。这样设计的原因和逻辑如下:
-
初始化:
dp[0]
和dp[1]
分别代表站在开始位置(楼梯底部)和第一阶楼梯时的最小成本。显然,这两个位置的成本是0,因为你还没有开始爬楼梯,也没有支付任何成本。 -
转移方程:对于
dp[i]
(i ≥ 2),它表示达到第i
阶的最小成本。你可以从第i-1
阶上来,这时你要支付dp[i-1] + cost[i-1]
的成本;或者你可以从第i-2
阶上来,这时你要支付dp[i-2] + cost[i-2]
的成本。dp[i]
应该取这两种情况的最小值,因为我们的目标是找到最小成本。 -
结束条件:注意,当
i = cost.length
时,你实际上已经“超出”了楼梯顶部一步,也就是达到了楼梯之上的位置。这时的dp[cost.length]
表示的是完全爬完楼梯所需的最小成本。因此,我们需要一个长度为cost.length + 1
的dp
数组来存储这个结果。 -
dp
数组长度:由于我们的目标是计算到达楼梯顶部(即“楼梯之上”)的最小成本,而不仅仅是到达最后一步的成本,所以dp
数组的长度比cost
数组多一个元素。这样可以确保我们考虑了所有可能的到达方式,包括直接从最后一步或倒数第二步跨步到楼梯顶端的情况。
-
-
注意
dp
数组初始化,与递推公式
不同路径
一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为 “Start” )。
机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为 “Finish” )。
问总共有多少条不同的路径?
按照动规五部曲来分析:
-
确定dp数组(dp table)以及下标的含义
dpi :表示从(0 ,0)出发,到(i, j) 有dpi条不同的路径。
-
确定递推公式
想要求dpi,只能有两个方向来推导出来,即dpi - 1 和 dpi。
此时在回顾一下 dpi - 1 表示啥,是从(0, 0)的位置到(i - 1, j)有几条路径,dpi同理。
那么很自然,dpi = dpi - 1 + dpi,因为dpi只有这两个方向过来。
-
dp数组的初始化
如何初始化呢,首先dpi一定都是1,因为从(0, 0)的位置到(i, 0)的路径只有一条,那么dp0也同理。
所以初始化代码为:
for (int i = 0; i < m; i++) dp[i][0] = 1; for (int j = 0; j < n; j++) dp[0][j] = 1;
-
确定遍历顺序
这里要看一下递推公式dpi = dpi - 1 + dpi,dpi都是从其上方和左方推导而来,那么从左到右一层一层遍历就可以了。
这样就可以保证推导dpi的时候,dpi - 1 和 dpi一定是有数值的。
-
举例推导dp数组
如图所示:
/** * 1. 确定dp数组下标含义 dp[i][j] 到每一个坐标可能的路径种类 * 2. 递推公式 dp[i][j] = dp[i-1][j] dp[i][j-1] * 3. 初始化 dp[i][0]=1 dp[0][i]=1 初始化横竖就可 * 4. 遍历顺序 一行一行遍历 * 5. 推导结果 。。。。。。。。 * * @param m * @param n * @return */ public static int uniquePaths(int m, int n) { int[][] dp = new int[m][n]; //初始化 for (int i = 0; i < m; i++) { dp[i][0] = 1; } for (int i = 0; i < n; i++) { dp[0][i] = 1; } for (int i = 1; i < m; i++) { for (int j = 1; j < n; j++) { dp[i][j] = dp[i-1][j]+dp[i][j-1]; } } return dp[m-1][n-1]; }
不同路径II
一个机器人位于一个 m x n
网格的左上角 (起始点在下图中标记为 “Start” )。
机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为 “Finish”)。
现在考虑网格中有障碍物。那么从左上角到右下角将会有多少条不同的路径?
网格中的障碍物和空位置分别用 1
和 0
来表示。
示例 1:
输入:obstacleGrid = [[0,0,0],[0,1,0],[0,0,0]] 输出:2 解释:3x3 网格的正中间有一个障碍物。 从左上角到右下角一共有 2 条不同的路径: 1. 向右 -> 向右 -> 向下 -> 向下 2. 向下 -> 向下 -> 向右 -> 向右
class Solution { public int uniquePathsWithObstacles(int[][] obstacleGrid) { int m = obstacleGrid.length; int n = obstacleGrid[0].length; int[][] dp = new int[m][n]; if(obstacleGrid[0][0] != 1){ dp[0][0] = 1; }else if(obstacleGrid[0][0] == 1){ dp[0][0] = 0; } for(int i = 1; i < m; i++){ dp[i][0] = dp[i-1][0]; if(obstacleGrid[i][0] == 1){ dp[i][0] = 0; } } for(int j = 1; j < n; j++){ dp[0][j] = dp[0][j-1]; if(obstacleGrid[0][j] == 1){ dp[0][j] = 0; } } for(int i = 1; i < m; i++){ for(int j = 1; j < n; j++){ if(obstacleGrid[i][j] != 1){ dp[i][j] = dp[i-1][j] + dp[i][j-1]; } } } return dp[m-1][n-1]; } }
class Solution { public int uniquePathsWithObstacles(int[][] obstacleGrid) { // 获取网格的行数和列数 int m = obstacleGrid.length; int n = obstacleGrid[0].length; // 初始化一个同样大小的dp数组,用于存储到达每个点的路径数 int[][] dp = new int[m][n]; // 如果起点或终点有障碍物,那么没有可行的路径,直接返回0 if (obstacleGrid[m - 1][n - 1] == 1 || obstacleGrid[0][0] == 1) { return 0; } // 初始化dp数组的第一列,如果某行的第一个格子没有障碍物, // 那么到达该格子的路径数为1(只能一直向下移动); // 遇到障碍物后,该列后面的格子都到达不了,路径数为0 for (int i = 0; i < m && obstacleGrid[i][0] == 0; i++) { dp[i][0] = 1; } // 同样地,初始化dp数组的第一行 for (int j = 0; j < n && obstacleGrid[0][j] == 0; j++) { dp[0][j] = 1; } // 从第二行第二列开始遍历整个网格, // 对于每个格子,如果没有障碍物,那么到达该格子的路径数等于 // 到达其左边格子和上面格子路径数的和,因为只能从左边或上面移动到该格子; // 如果有障碍物,到达该格子的路径数为0 for (int i = 1; i < m; i++) { for (int j = 1; j < n; j++) { if (obstacleGrid[i][j] == 0) { dp[i][j] = dp[i - 1][j] + dp[i][j - 1]; } else { dp[i][j] = 0; } } } // 返回到达右下角格子的路径数 return dp[m - 1][n - 1]; } }
注意细节
-
如何获取int obstacleGrid的行列数量
-
行数:二维数组的行数可以通过
obstacleGrid.length
直接获取。 -
列数:如果二维数组至少有一行,那么可以通过
obstacleGrid[0].length
获取第一行的列数。注意,这假设二维数组中的所有行都有相同的列数(即数组是“矩形”的,这在大多数情况下是成立的)。
-
-
注意遍历时,如果有障碍物的情况
for (int i = 1; i < m; i++) { for (int j = 1; j < n; j++) { if (obstacleGrid[i][j] == 1) continue; dp[i][j] = dp[i - 1][j] + dp[i][j - 1]; } }
for (int i = 0; i < m && obstacleGrid[i][0] == 0; i++) { dp[i][0] = 1; }
这个循环的条件是
i < m && obstacleGrid[i][0] == 0
。这意味着循环会继续执行直到两个条件之一不再满足:要么i
等于m
(即遍历到了第一列的底部),要么obstacleGrid[i][0]
不为0(即遇到了障碍物)。一旦遇到障碍物,循环就会停止,因为&&
操作符要求两个条件都必须为真才会执行循环体内的代码。所以,如果在第一列中的某个位置
i
有一个障碍物,那么从这个位置开始往后的所有位置在dp
数组中对应的值会保持为初始化时的值0,而不是1。这是因为一旦遇到障碍物,就不可能有任何路径能到达该列中障碍物之后的任何位置,因此这些位置的路径数自然为0。 -
注意起点或终点有障碍的情况
//如果在起点或终点出现了障碍,直接返回0 if (obstacleGrid[m - 1][n - 1] == 1 || obstacleGrid[0][0] == 1) { return 0; }
回溯算法
递增子序列
给定一个整型数组, 你的任务是找到所有该数组的递增子序列,递增子序列的长度至少是2。
示例:
-
输入: [4, 6, 7, 7]
-
输出: [[4, 6], [4, 7], [4, 6, 7], [4, 6, 7, 7], [6, 7], [6, 7, 7], [7,7], [4,7,7]]
为了有鲜明的对比,我用[4, 7, 6, 7]这个数组来举例,抽象为树形结构如图:
同一父节点下的同层上使用过的元素就不能再使用了
其实用数组来做哈希,效率就高了很多。
注意题目中说了,数值范围[-100,100],所以完全可以用数组来做哈希。
程序运行的时候对unordered_set 频繁的insert,unordered_set需要做哈希映射(也就是把key通过hash function映射为唯一的哈希值)相对费时间,而且每次重新定义set,insert的时候其底层的符号表也要做相应的扩充,也是费事的。
class Solution { // 结果列表,存储所有合法的递增子序列 List<List<Integer>> res = new ArrayList<>(); // 用于存储当前正在构建的一个递增子序列 List<Integer> path = new ArrayList<>(); public List<List<Integer>> findSubsequences(int[] nums) { // 从数组的第一个元素开始进行回溯搜索 backtrack(nums, 0); return res; } private void backtrack(int[] nums, int startIndex){ // 如果当前路径包含的元素超过1个,则将其作为一个合法的递增子序列添加到结果列表中 if(path.size() > 1){ res.add(new ArrayList<>(path)); } // 使用一个HashSet来避免在同一层递归中选择相同的元素,从而避免产生重复的子序列 HashSet<Integer> set = new HashSet<>(); for(int i = startIndex; i < nums.length; i++){ // 如果当前元素已经被选择过,或者当前元素小于路径中的最后一个元素(不满足递增关系),则跳过 if(set.contains(nums[i]) || (!path.isEmpty() && path.get(path.size()-1) > nums[i])){ continue; } // 记录当前选择的元素,避免同一层递归中的重复 set.add(nums[i]); // 将当前元素加入到路径中 path.add(nums[i]); // 从当前元素的下一个位置开始递归搜索 backtrack(nums, i+1); // 回溯,移除路径中的最后一个元素,以尝试其他可能的子序列组合 path.remove(path.size() - 1); } } }
注意细节
-
要注意不重复要用
startIndex
-
不重复且递增,用
HashSet
判断是否重复元素,用path
的最后一个元素与当前元素比较大小判断是否递增,还要注意!path.isEmpty()
4.9
回溯算法
全排列
给定一个 没有重复 数字的序列,返回其所有可能的全排列。
示例:
-
输入: [1,2,3]
-
输出: [ [1,2,3], [1,3,2], [2,1,3], [2,3,1], [3,1,2], [3,2,1] ]
class Solution { List<Integer> path = new ArrayList<>(); List<List<Integer>> res = new ArrayList<>(); public List<List<Integer>> permute(int[] nums) { int[] used = new int[nums.length]; backtrack(nums, 0, used); return res; } private void backtrack(int[] nums, int startIndex, int[] used){ if(path.size() == nums.length){ res.add(new ArrayList<>(path)); return; } for(int i = 0; i < nums.length; i++){ if(used[i] == 0){ used[i] = 1; path.add(nums[i]); backtrack(nums, i+1, used); path.removeLast(); used[i] = 0; } } } }
class Solution { List<List<Integer>> result = new ArrayList<>();// 存放符合条件结果的集合 LinkedList<Integer> path = new LinkedList<>();// 用来存放符合条件结果 boolean[] used; public List<List<Integer>> permute(int[] nums) { if (nums.length == 0){ return result; } used = new boolean[nums.length]; permuteHelper(nums); return result; } private void permuteHelper(int[] nums){ if (path.size() == nums.length){ result.add(new ArrayList<>(path)); return; } for (int i = 0; i < nums.length; i++){ if (used[i]){ continue; } used[i] = true; path.add(nums[i]); permuteHelper(nums); path.removeLast(); used[i] = false; } } }
-
每层都是从0开始搜索而不是startIndex
-
需要used数组记录path里都放了哪些元素了
动态规划
整数拆分
给定一个正整数 n,将其拆分为至少两个正整数的和,并使这些整数的乘积最大化。 返回你可以获得的最大乘积。
示例 1:
-
输入: 2
-
输出: 1
-
解释: 2 = 1 + 1, 1 × 1 = 1。
示例 2:
-
输入: 10
-
输出: 36
-
解释: 10 = 3 + 3 + 4, 3 × 3 × 4 = 36。
-
说明: 你可以假设 n 不小于 2 且不大于 58。
动规五部曲,分析如下:
-
确定dp数组(dp table)以及下标的含义
dp[i]:分拆数字i,可以得到的最大乘积为dp[i]。
dp[i]的定义将贯彻整个解题过程,下面哪一步想不懂了,就想想dp[i]究竟表示的是啥!
-
确定递推公式
可以想 dp[i]最大乘积是怎么得到的呢?
其实可以从1遍历j,然后有两种渠道得到dp[i].
一个是j * (i - j) 直接相乘。
一个是j * dp[i - j],相当于是拆分(i - j),对这个拆分不理解的话,可以回想dp数组的定义。
j是从1开始遍历,拆分j的情况,在遍历j的过程中其实都计算过了。那么从1遍历j,比较(i - j) * j和dp[i - j] * j 取最大的。递推公式:dp[i] = max(dp[i], max((i - j) * j, dp[i - j] * j));
-
dp的初始化
严格从dp[i]的定义来说,dp[0] dp[1] 就不应该初始化,也就是没有意义的数值。
这里我只初始化dp[2] = 1,从dp[i]的定义来说,拆分数字2,得到的最大乘积是1。
-
确定遍历顺序
确定遍历顺序,先来看看递归公式:dp[i] = max(dp[i], max((i - j) * j, dp[i - j] * j));
dp[i] 是依靠 dp[i - j]的状态,所以遍历i一定是从前向后遍历,先有dp[i - j]再有dp[i]。
所以遍历顺序为:
for (int i = 3; i <= n ; i++) { for (int j = 1; j < i - 1; j++) { dp[i] = max(dp[i], max((i - j) * j, dp[i - j] * j)); } }
注意 枚举j的时候,是从1开始的。从0开始的话,那么让拆分一个数拆个0,求最大乘积就没有意义了。
j的结束条件是 j < i - 1 ,其实 j < i 也是可以的,不过可以节省一步,例如让j = i - 1,的话,其实在 j = 1的时候,这一步就已经拆出来了,重复计算,所以 j < i - 1
至于 i是从3开始,这样dp[i - j]就是dp[2]正好可以通过我们初始化的数值求出来。
更优化一步,可以这样:
for (int i = 3; i <= n ; i++) { for (int j = 1; j <= i / 2; j++) { dp[i] = max(dp[i], max((i - j) * j, dp[i - j] * j)); } }
因为拆分一个数n 使之乘积最大,那么一定是拆分成m个近似相同的子数相乘才是最大的。
例如 6 拆成 3 * 3, 10 拆成 3 * 3 * 4。 100的话 也是拆成m个近似数组的子数 相乘才是最大的。
只不过我们不知道m究竟是多少而已,但可以明确的是m一定大于等于2,既然m大于等于2,也就是 最差也应该是拆成两个相同的 可能是最大值。
那么 j 遍历,只需要遍历到 n/2 就可以,后面就没有必要遍历了,一定不是最大值。
至于 “拆分一个数n 使之乘积最大,那么一定是拆分成m个近似相同的子数相乘才是最大的” 这个我就不去做数学证明了,感兴趣的同学,可以自己证明。
5.举例推导dp数组
举例当n为10 的时候,dp数组里的数值,如下:
class Solution { public int integerBreak(int n) { //dp[i] 为正整数 i 拆分后的结果的最大乘积 int[] dp = new int[n+1]; dp[2] = 1; for(int i = 3; i <= n; i++) { for(int j = 1; j <= i-j; j++) { // 这里的 j 其实最大值为 i-j,再大只不过是重复而已, //并且,在本题中,我们分析 dp[0], dp[1]都是无意义的, //j 最大到 i-j,就不会用到 dp[0]与dp[1] dp[i] = Math.max(dp[i], Math.max(j*(i-j), j*dp[i-j])); // j * (i - j) 是单纯的把整数 i 拆分为两个数 也就是 i,i-j ,再相乘 //而j * dp[i - j]是将 i 拆分成两个以及两个以上的个数,再相乘。 } } return dp[n]; } }
这段代码使用动态规划求解整数拆分问题。在内层循环中,条件j <= i - j
用于确保在拆分整数i
时,遍历到i
的一半即可。这是因为当你拆分一个整数i
为两部分j
和i-j
时,不需要遍历整个范围到i
,因为拆分为j
和i-j
与拆分为i-j
和j
是等价的。限制j
到i - j
可以减少不必要的重复计算,提高效率。
-
遍历的目的:在遍历过程中,我们试图找到最优的拆分方法,使得
dp[i]
(即拆分整数i
所能得到的最大乘积)最大。对于每个i
,我们检查所有可能的拆分方式,即将i
拆分为j
和i-j
。 -
优化遍历范围:对于整数
i
的拆分,当你考虑了j
和i-j
的拆分后,再考虑i-j
和j
的拆分就是重复的。例如,拆分整数6
为2
和4
与拆分为4
和2
是相同的情况。因此,只需要遍历到i
的一半。 -
数学解释:当
j
超过i/2
时,i-j
就会小于j
,这意味着我们已经在之前的迭代中考虑过这个拆分(即当我们以i-j
作为第一个数,j
作为第二个数时)。
class Solution { public int integerBreak(int n) { // 创建动态规划数组dp,大小为n+1,因为我们要从1计算到n,包含n int[] dp = new int[n+1]; // 初始化条件:整数2拆分为1和1,乘积最大为1 dp[2] = 1; // 从整数3开始,计算每个整数i的最大乘积 for(int i = 3; i <= n; i++){ // 对于每个整数i,尝试所有可能的拆分方案 // 由于拆分i为j和i-j与拆分为i-j和j是等价的,因此只需遍历到i的一半即可 for(int j = 1; j <= i/2; j++){ // 计算当前拆分方案的乘积,并与已有的最大乘积进行比较,取较大者 // Math.max(j*(i-j), j*dp[i-j])比较直接拆分为j和i-j的乘积,以及拆分为j和将i-j继续拆分的最大乘积 dp[i] = Math.max(dp[i], Math.max(j*(i-j), j*dp[i-j])); } } // 返回整数n拆分后可以得到的最大乘积 return dp[n]; } }
注意细节
-
dp[i]
意思是 对i
进行拆分的最大乘积是dp[i]
-
j
遍历1
到i
拆分两个数是
j * i-j
拆分成多个数是
j * dp[i-j]