560. 和为 K 的子数组(前缀和计数map)
假设 left 到 right 下标的子数组和为 k nums[left...right] = k preSum[right] - preSum[left] = k preSum[left] = preSum[right] - k 所以每次到 right 的时候,找到等于 preSum[right] - k 的 preSum[left] 有多少个
用一个 map 来记录,前缀和的 count (见官方题解动画)
key: 前缀和的值
value: 前缀和为这个值的个数
注意!!!:
- map 要放入一个初始值 {0,1}
- 一定要先 getPreSumCount ,再把加上当前节点的 PreSum 放进 map +1 (因为当前节点的和不属于当前节点的前缀和)
class Solution { public int subarraySum(int[] nums, int k) { Map<Integer, Integer> leftPreSum2CntMap = new HashMap(); leftPreSum2CntMap.put(0, 1); int rightPreSum = 0; // 总的满足条件的子数组个数的计数,最后的结果 int count = 0; for (int right=0;right<nums.length;right++) { rightPreSum += nums[right]; // [...i] // left 到 right 的子数组和为 k // nums[left...right] = k // preSum[right] - preSum[left] = k // preSum[left] = preSum[right] - k // 所以每次到 right 的时候,找到等于 preSum[right] - k 的 preSum[left] 有多少个 Integer leftPreSumCnt = leftPreSum2CntMap.get(rightPreSum - k); if (leftPreSumCnt != null) { count += leftPreSumCnt; } // 将这次的和放到 map 里 Integer rightPreSumCnt = leftPreSum2CntMap.get(rightPreSum); if (rightPreSumCnt == null) { rightPreSumCnt = 0; } leftPreSum2CntMap.put(rightPreSum, ++rightPreSumCnt); } return count; } }
53. 最大子数组和(动态规划)
- dp[i]=max{nums[i],dp[i−1]+nums[i]}
- dp[0]= nums[0]
class Solution { public int maxSubArray(int[] nums) { int result = nums[0]; int dp[] = new int[nums.length]; dp[0] = nums[0]; for(int i=1;i<nums.length;i++){ //递推公式 dp[i] = Math.max(dp[i-1]+nums[i],nums[i]); //result用来记dp数组里的最大数值 if(dp[i]>result) result = dp[i]; } return result; } }
128. 最长连续序列(全放set里 num-1 不存在则一直往后)
两种最朴素的解法之一:
- 先排序,从前往后找最长连续上升序列即可。该思路简单有效,但是复杂度已经至少有 O(nlogn)。实现起来也比较简单,在此不讨论该解法。
- 遍历数组中的每个元素num,然后以num为起点,每次+1向后遍历num+1,num+2,num+3...,判断这些元素是否存在于数组中。假设找到的最大的连续存在的元素为num+x,那么这个连续序列的长度即为x+1。最后将每个num所开始序列长度取个最大值即可。
解题思路1:哈希集合
方法 2 不用想,是肯定超时的。它的最坏时间复杂度已经达到了 O(n^3)
我们需要优化代码。优化的点主要有两个:
- 判断num+1,num+2,num+3...是否在数组中。上面的代码是用直接遍历的方式去查找的,时间复杂度为 O(n) 。我们可以改为哈希表查找,时间复杂度为 O(1)
- 遍历数组中每个元素num。逐一遍历每个元素会产生很多冗余工作,实际上我们无需一次针对每个元素num去判断num+1,num+2,num+3...是否在数组中。如果num-1已经在数组中的话,那么num-1肯定会进行相应的+1遍历,然后遍历到num,而且从num-1开始的+1遍历必定比从num开始的+1遍历得到的序列长度更长。因此,我们便可将在一个连续序列中的元素进行删减,让其只在最小的元素才开始+1遍历。比如,现有元素[1,2,4,3,5],当2,3,4,5发现均有比自己小1的元素存在,那么它们就不会开始+1遍历,而1是连续序列中最小的元素,没有比自己小1的元素存在,所以会开始+1遍历。通过上述方式便可将时间复杂度优化至O(n)。
解法描述:
1、先排序再找最长递增子序列
最长递增子序列在已排序 且 没有重复元素的情况下可用 dp 解决
- dp[0] = 1
- if(nums[i] == nums[i-1]+1) dp[i] = dp[i-1]+1
- if(nums[i] != nums[i-1]+1) dp[i] = 1
- res = Max(res, dp[i])
但是对于有重复元素的情况,比如 [1,2,0,1] (排序后是 [0,1,1,2])预期结果是 3 不是 2,两个 1 不会中断连续的定义
所以如果想要用 dp 来做的话,要先去重,去重后再排序,然后再 dp
2、哈希表(O(n))
- 先将所有元素都加入到 HashSet 中
- 遍历 HashSet,当前数字记为 num:
- 如果 Set 中不存在 num-1,那么往后找 num+1,num+2,num+3.... 直到没有,那么结果就是 每次 在这段连续最长里面 找最大的那个
class Solution { //哈希表实现。哈希表的 插入、删除、查找的时间复杂度近似为 O(1) public int longestConsecutive2(int[] nums) { if (nums.length == 0) { return 0; } Set<Integer> set = new HashSet(); // 每个加到哈希表里(去重) for (int i=0;i<nums.length;i++) { set.add(nums[i]); } int ans = 1; for (int num:set) { int cur = num; // 如果 num-1 存在,那么 num 就不用搜索了,搜索 num-1 的时候一直往后 +1 搜索会把 num 包括进去 // 当 num-1 不存在,才开始往后搜索 if (!set.contains(num-1)) { // 只有当num-1不存在时,才开始向后遍历num+1,num+2,num+3...... while (set.contains(cur+1)) { cur++; } // [num, cur] 是连续的区间 ans = Math.max(ans, cur-num+1); } } return ans; } public int longestConsecutive(int[] nums) { if (nums.length == 0) { return 0; } int res = 1; Set<Integer> set = new HashSet(); // 每个加到哈希表里(去重) for (int i=0;i<nums.length;i++) { set.add(nums[i]); } // 去重之后长度会缩短 int newLen = 0; int[] newNums = new int[set.size()]; for (int num : set) { newNums[newLen++] = num; } // 去重之后再排序 Arrays.sort(newNums); // 去重&排序之后用dp // 这样复杂度取决于排序的复杂度 // dp[i] 是 0~i 的最长递增子序列 // dp[i] = dp[i-1] + 1 if(nums[i]==nums[i-1]+1) // dp[i] = 1 if(nums[i]!=nums[i-1]+1) // 结果为 max(dp[]) int[] dp = new int[newLen]; dp[0] = 1; for (int i=1;i<dp.length;i++) { if (newNums[i] == newNums[i-1]+1) { dp[i] = dp[i-1] + 1; } else { dp[i] = 1; } res = Math.max(res, dp[i]); } return res; } }
300. 最长递增子序列
dp[i] 表示 nums[0:i] 的最长递增子序列
枚举所有 <i 的 j,即在 nums[i] 之前的所有递增子序列
如果 nums[i]>nums[j],dp[j] 是 [0:j] 的最长,又加上 nums[i] 这一个比 nums[j] 大的
dp[i] = Math.max(dp[i], dp[j] + 1);
class Solution { public int lengthOfLIS(int[] nums) { int res=1; // dp[i] nums[0:i] 的最长递增子序列 int[] dp = new int[nums.length]; for (int i=0;i<dp.length;i++) { // 最短默认1 dp[i] = 1; for (int j=0;j<i;j++) { if (nums[i]>nums[j]) { // dp[j] 是 [0:j] 的最长,又加上nums[i]这一个比nums[j]大的 dp[i] = Math.max(dp[i], dp[j] + 1); } } res = Math.max(res, dp[i]); } return res; } }
438. 找到字符串中所有字母异位词(滑动窗口map差异计数)
维持一个 map ,如果这个 map 的每项值都为 0,说明
- key : p 中的字母
- value:当前窗口与 p 中字母数量的差距
map 初始化:遍历 p ,统计 p 中每个字母的数量,放到 map 中
左右指针:l = 0; r = 0
滑动窗口初始化:从 0 到 < p.length 只移动右指针 r,每进来 s 的一个字符,判断其如果在 map 中,说明这个字符与 p 中这个字符数量的差距-1,将其计数 -1
在这之后 l r 同步移动:
lc 表示 s 中 l 下标的字符,rc 表示 s 中 r 下标的字符
- 旧 l 要出去了,如果 map 里包含 lc,说明这个曾经抵消掉一个 p 中的该字符数量,但现在要出去了,不能抵消了,map 中的差异计数 +1
- 新 r 要进来了,如果 map 里包含 rc,说明现在这个新来的字符可以抵消掉一个 p 中的该字符数量,map 中的差异计数-1
- 每次遍历 map,如果 map 中所有的值为 0 ,说明窗口里现在的字串与 p 中的字符计数没有差异,将 l 加到结果中
class Solution { public List<Integer> findAnagrams(String s, String p) { List<Integer> res = new ArrayList(); if (s.length() < p.length()) { return res; } Map<Character, Integer> map = new HashMap(); for(int i=0;i<p.length();i++) { map.put(p.charAt(i), map.getOrDefault(p.charAt(i), 0)+1); } int l=0; int r=0; // 滑动窗口初始化 for (int i=0;i<p.length();i++) { char si = s.charAt(i); if (map.containsKey(si)) { map.put(si, map.get(si)-1); } r++; } if (mapAllZero(map)) { res.add(0); } while(l<r && r<s.length()) { char lc = s.charAt(l); // 左边的元素出去,map 中如果存在的话,则其数量 +1 if (map.containsKey(lc)) { map.put(lc, map.get(lc)+1); } // 右边的元素进来,map 中如果存在的话,则其数量 -1 char rc = s.charAt(r); if (map.containsKey(rc)) { map.put(rc, map.get(rc)-1); } if (mapAllZero(map)) { res.add(l+1); } l++; r++; } return res; } private boolean mapAllZero(Map<Character, Integer> map) { for (Map.Entry<Character, Integer> entry : map.entrySet()) { if (entry.getValue() != 0) { return false; } } return true; } }
76. 最小覆盖子串(滑动窗口 map 差异计数)
和前面的 438.找到字符串中所有字母异位词 解法一样
用滑动窗口,维持当前窗口和字串 t 中的字符数差值
不同的是,前面那个题要求子串连续,滑动窗口的大小是固定的
而这个题要求覆盖即可,滑动窗口的大小是可变的,因此每个循环
- 如果当前窗口的字符个数 可以覆盖子串的,那么 l 右移,看可不可以使窗口进一步缩短
- 如果当前窗口的字符个数 不可以覆盖子串的,那么 r 右移,看新加进来的字符可不可使当前窗口可以覆盖
需要注意的是退出循环的条件,并不是 r>s.length()
例如:
s = "ADOBECODEBANC", t = "ABC" 答案是 BANC
当 r 指向最后一个的时候,l 还指向的是 2,也就是说当前窗口是 OBECODEBANC, 可以覆盖
但是此时循环还要继续,l 可以继续右移,使窗口进一步缩短,到最小覆盖子串 BANC
而对于另一种情况,也就是说 r 到了末尾,但是现在的窗口不能覆盖子串那么 l 无论怎么左移,剩下的也都不能覆盖子串了
因此,退出循环的条件就是 r 到了末尾,而此时的窗口不能覆盖子串
其它注意事项:
- map 初始化:遍历 t,统计每个字符的个数,放入 map
- 判断当前窗口可以覆盖子串的方法:看 map 里所有的 value 是不是 <=0(>0 不能覆盖,=0 个数相等刚好覆盖,<0 覆盖了之后还有多的)
class Solution { public String minWindow(String s, String t) { String res; int resStartIndex=0; int resEndIndex=0; int resShortestLen = Integer.MAX_VALUE; Map<Character, Integer> curWinMap = new HashMap(); // 初始化 map, 放入 t 串每个字符的计数 for (int i=0;i<t.length();i++) { curWinMap.put(t.charAt(i), curWinMap.getOrDefault(t.charAt(i), 0) + 1); } int l = 0; int r = 0; while (true) { boolean winContainsAllt = winContainsAllt(curWinMap); if ((r-l) < resShortestLen && winContainsAllt) { resStartIndex = l; resEndIndex = r; resShortestLen = r-l; } if (winContainsAllt) { char lc = s.charAt(l); // 如果已经包含全部了。那么左边的出去,看能不能继续缩短一些 if (curWinMap.containsKey(lc)) { // 左边的出去了一个可以 cover t 串的,差距计数 +1 curWinMap.put(lc, curWinMap.get(lc) + 1); } l++; } else { // 如果 r 到末尾了,当前窗口可以覆盖,循环还是要继续往下走,看 l 能不能右移使窗口更缩短 // 如果 r 到末尾了,当前窗口的还不能覆盖,那 l 继续往右走后面更都不能覆盖了,所以可以退出循环了 if (r == s.length()) { break; } // 这个要在前面那个 break 的后面,防止数组越界 char rc = s.charAt(r); // 没有包含全部,右边的继续进来新的,看新来的能不能覆盖 if (curWinMap.containsKey(rc)) { // 右边新进来的这个可以 cover t 串,差距计数 -1 curWinMap.put(rc, curWinMap.get(rc) - 1); } r++; } } return s.subSequence(resStartIndex, resEndIndex).toString(); } private boolean winContainsAllt(Map<Character, Integer> curWinMap) { for (Map.Entry<Character, Integer> entry : curWinMap.entrySet()) { // 如果不够 cover 掉 t 里面的字符 // >0 不能 cover; =0 完全 cover ,字符计数相等; <0 完全 cover,并且还有多的 if (entry.getValue() > 0) { return false; } } // 全部 cover 的情况是每一项都 < 0 return true; } }
3. 无重复字符的最长子串(滑动窗口 map 记录字符上次出现的位置)
维持一个滑动窗口,使窗口内始终维持没有重复字符,计算此窗口的最大值
怎么实现滑动窗口内没有重复字符呢?
用一个 map ,记录每个字符上一次出现的位置
每次移动 r ,让右边新字符加进来,根据 map 获得这个新字符上次出现的位置
如果在当前窗口内(即>左指针l),说明在当前窗口内
因此要移动左指针到 上次出现的位置 + 1,把这个重复字符排出当前窗口内
class Solution { public int lengthOfLongestSubstring(String s) { if (s == null || "".equals(s)) { return 0; } int res = Integer.MIN_VALUE; int l = 0; int r = 0; // 记录字符上一次出现的位置。 Map<Character, Integer> char2LastIndexMap = new HashMap<>(); while (r<s.length()) { char rc = s.charAt(r); Integer rcLastIndex = char2LastIndexMap.get(rc); // 对于右边新加入的字符,要维持滑动窗口内无重复的 // 就要看它上次出现的位置是不是在窗口内,也就是说它上次出现的位置 > l左指针 // 如果在窗口内,就将 l 移动到它上次出现的位置 +1 if (rcLastIndex != null && rcLastIndex >= l) { l = rcLastIndex + 1; } // 为什么要 + 1,应为这里已经把 r 当成新加进来的了 if (r-l+1 > res) { res = r-l+1; } char2LastIndexMap.put(rc ,r); r++; } return res; } }
1143. 最长公共子序列 (动态规划,dp[i][j] s1[0:i]和s2[0:j]最长)
dp[i][j] 表示 text1(0:i) 和 text2(0:j) 的最长公共子序列的长度
用 dp 的 0 行 0 列表示字符串为空的场景,即 i=0 或 j=0 的 0 行 0 列,dp 值为0
因此 dp 对应的下标比字符串的下标多1
class Solution { public int longestCommonSubsequence(String text1, String text2) { // dp[i][j] 表示 text1(0...i) 与 text2(0...j) 的最长公共子序列 // dp[0] 用来表示空字符串的情况了,所以要 length+1 int[][] dp=new int[text1.length()+1][text2.length()+1]; // 要由左上角得来,所以从左上到右下 // text1[i] == text2 dp[i][j] = dp[i-1][j-1]+1 // text1[i] != text2 dp[i][j] = Math.max(dp[i-1][j], dp[i][j-1]) int maxLen = 0; for (int i=1;i<text1.length();i++) { dp[i][0] = 0; } for (int j=1;j<text2.length();j++) { dp[0][j] = 0; } for (int i=1;i<text1.length()+1;i++) { // dp[0] 用来表示空字符串的情况了,所以要 length+1 // dp[1] 才是 text.charAt(0),所以要 -1 这里 char c1=text1.charAt(i-1); for (int j=1;j<text2.length()+1;j++) { char c2=text2.charAt(j-1); if (c1 == c2) { dp[i][j] = dp[i-1][j-1] + 1; if (dp[i][j] > maxLen) { maxLen = dp[i][j]; } } else { dp[i][j] = Math.max(dp[i-1][j], dp[i][j-1]); } } } return maxLen; } }
最长公共子串 (动态规划+Map存起始下标)
和最长公共子序列类似
dp[i][j] 表示 text1(0:i) 和 text2(0:j) 的最长公共子串的长度
用 dp 的 0 行 0 列表示字符串为空的场景,即 i=0 或 j=0 的 0 行 0 列,dp 值为0
只是状态转移方程,在比较的当前字符不等时,不一样
- dp[i][j] = dp[i-1][j-1]+1, text1(i-1)==text2(i-1)
- dp[i][j] = 0, text1(i-1) != text2(i-1)
- key为 dp[i][j] 的i,即text1的子串结束下标
- value为text1(0:i)的最长公共子串起始下标
import java.util.HashMap; import java.util.Map; public class TestLongestCommonSubStr { public static void main(String[] args) { String res = longestCommonSubsequence("rghello123wohellord66665rld", "vfhello123abc4hellord66665"); System.out.println(res); } public static String longestCommonSubsequence(String text1, String text2) { // dp[i][j] 表示 text1(0...i) 与 text2(0...j) 的最长公共子序列 // dp[0] 用来表示空字符串的情况了,所以要 length+1 int[][] dp=new int[text1.length()+1][text2.length()+1]; // 要由左上角得来,所以从左上到右下 // text1[i] == text2 dp[i][j] = dp[i-1][j-1]+1 // text1[i] != text2 dp[i][j] = Math.max(dp[i-1][j], dp[i][j-1]) // key为dp[i][j]的i,即text1的子串结束下标,value为text1(0:i)的最长公共子串起始下标 Map<Integer, Integer> map = new HashMap<>(); int maxLen = 0; int[] maxLenIndex = new int[2]; for (int i=1;i<text1.length();i++) { dp[i][0] = 0; } for (int j=1;j<text2.length();j++) { dp[0][j] = 0; } for (int i=1;i<text1.length()+1;i++) { // dp[0] 用来表示空字符串的情况了,所以要 length+1 // dp[1] 才是 text.charAt(0),所以要 -1 这里 char c1=text1.charAt(i-1); for (int j=1;j<text2.length()+1;j++) { char c2=text2.charAt(j-1); if (c1 == c2) { dp[i][j] = dp[i-1][j-1] + 1; // 起始下标从 i-1 来 int istart = put(map, i-1, i); if (dp[i][j] > maxLen) { // 最长公共子串的长度 maxLen = dp[i][j]; // 最长公共子串在 text1 的起始下标 maxLenIndex[0] = istart; // 最长公共子串在 text1 的结束下标 maxLenIndex[1] = i; } } else { // 子序列 //dp[i][j] = Math.max(dp[i-1][j], dp[i][j-1]); // 子串 dp[i][j] = 0; } } } return text1.subSequence(maxLenIndex[0], maxLenIndex[1]).toString(); } private static int put(Map<Integer, Integer> map, int istartFrom, int i) { // istartFrom 起始下标从 istartFrom 来 Integer istart = map.get(istartFrom); if (istart == null) { // 当 i=1 时会走到这里 map.put(i, i); return i; } else { // 放入 结束下标-起始下标 map.put(i, istart); return istart; } } }
131. 分割回文串 (dfs 所有组合)
有点像上面的组合
- 起始位置是上一层的结束位置 +1
- 判断是 回文串 之后,再往下 dfs
- 每一层是 起始位置~结束位置 遍历
- 当起始位置==s.length() 时添加结果,退出循环
由于每个节点都要判断是否是回文串,所以可以先对整个字符串求出 dp :
boolean dp[i][j] 表示字符串从 i 到 j 是否是回文串:
- if i==j dp[i][j]=true
- else if s.charAt(i) != s.charAt(j) dp[i][j]=false;
- else if j-i==1(如"aa") dp[i][j]=true
- else dp[i][j]=dp[i+1][j-1]
dp[i][j]=dp[i+1][j-1] 由于需要从左下角的元素推出现在的元素
所以遍历应该是从左下到右上——从下到上&从左到右
即从下到上: i 从 n-1 到 0
从左到右(又j>i):j 从 i 到 n
class Solution { List<List<String>> res = null; Solution() { res = new ArrayList(); } public List<List<String>> partition(String s) { boolean[][] dp = getDP(s); dfs(s, dp, new LinkedList(), 0); return res; } private void dfs(String s, boolean[][] dp, LinkedList<String> curRes, int startIndex) { // startIndex 越界,添加结果集 if (startIndex==s.length()) { res.add(new ArrayList(curRes)); } for (int j0=startIndex;j0<s.length();j0++) { //substring 方法i,j不包含j,而其它地方包括dp认为的i,j都是包括j的。 // 所以这里 substring 的时候要 j0+1 String substr = s.substring(startIndex, j0+1); // 是回文串才往下 // 和八皇后一样,这里不能在前面循环外面 return,而是符合条件才往下。因外外面没有 j0? if (dp[startIndex][j0]) { curRes.addLast(substr); // 如 aabcb, startIndex是0,j0现在等于1,那么已经分出来了是回文的aa // 那么后面就要继续分bcb,即startIndex是j+1,里面循环j+1到length分bcb dfs(s, dp, curRes, j0+1); curRes.removeLast(); } } } private boolean[][] getDP(String s) { int n=s.length(); boolean[][] dp = new boolean[n][n]; // dp[i][j] s的i到j是否是回文 // dp[i][j] = dp[i+1][j-1]; // 需要通过左下角的结果推出来,所以遍历顺序应该是从左下到右上 // i从下到上 for (int i=n-1;i>=0;i--) { // 由于是从i到j,所以j永远是大于i的,所以j从i开始往上增 // j从左到右 for (int j=i;j<n;j++) { if (i==j) { dp[i][j] = true; } else if (s.charAt(i) != s.charAt(j)) { dp[i][j] = false; } // 只有两个元素时,而且此时已经通过了上面的s.charAt(i) != s.charAt(j) // 举例,如 aa else if (j-i==1) { dp[i][j] = true; } else { // 需要通过左下角的结果推到出来 // 所以 dp 遍历的顺序应该是从左下到右上 dp[i][j] = dp[i+1][j-1]; } } } return dp; } }
763. 划分字母区间 (贪心,用结束下标更新当前片段结束下标)
先遍历字符串,得到 字符-该字符最后一次出现的位置 的map
用一个 curStart 和 curEnd 来记录当前片段的起始下标和结束下标,初始值都为0
然后再次遍历字符串,如果 新字符的endIndex > curEnd,超出了当前片段,则扩充当前片段结束边界:curEnd = 字符endIndex
如果 i==curEnd ,经过前面的扩充,与一些字符的不扩充,终于走到了片段的结束
则将当前片段的长度 curEnd-curStart+1 添加到结果数组中,同时下一个片段要开始了:curStart=i+1 curEnd=i+1
class Solution { public List<Integer> partitionLabels(String s) { // 得到 字符-该字符最后一次出现位置 的map Map<Character, Integer> char2EndMap = new HashMap(); for (int i=0;i<s.length();i++) { char c = s.charAt(i); char2EndMap.put(c, i); } List<Integer> res = new ArrayList(); // 当前片段起始位置 int curStart = 0; // 当前片段结束位置 int curEnd = 0; for (int i=0;i<s.length();i++) { char c = s.charAt(i); Integer cEndIndex = char2EndMap.get(c); // 这个片段内出现的新字符的endIndex大于当前片段的end,则更新 if (cEndIndex>curEnd) { curEnd = cEndIndex; } // 当前片段走到了最后一个字符 if (i == curEnd) { // 当前片段已经结束,将当前片段的长度添加到结果中 res.add(curEnd - curStart + 1); // 现在下个片段开始了 curStart = i+1; curEnd = i+1; } } return res; } }
392. 判断子序列(双指针)
为短串中的每一个字符,依次在长串中寻找。i=0 j=0
如果相等,两个都移动;如果不相等,只长串中的那个移动,去为短串当前字符继续寻找相等的
class Solution { public boolean isSubsequence(String s, String t) { int i=0; int j=0; while (i<s.length() && j<t.length()) { // 如果相等,都移动一步 if (s.charAt(i) == t.charAt(j)) { i++; j++; } // 如果不相等,长的那个移动一步去为"子序列"寻找下一个数字 else { j++; } } // 如果 "子序列" 走完了,说明是子序列 return i==s.length(); } }