双指针
- 一、什么是双指针?
- 1.1两数之和Ⅱ(简单)(直接用双指针)
- 1.2反转字符串(简单)(原地)(直接用双指针)
- 1.3盛最多水的容器(中等)
- 1.4二分查找(本质上也是双指针)
- 1.5移除元素(简单)
- 1.6删除有序数组的重复元素(简单)
- ※1.7比较含退格的字符串(简单)
- ※二、合并两个有序数组(简单)(三指针、逆向指针)
- 三、快慢指针(简单)(环形链表、回文链表),特有题型
- 3.1环形链表Ⅱ(中等)
- 3.2回文链表(简单)
- 3.3删除链表的倒数第N个结点(中等)
- 四、※高阶用法:滑动窗口(背模板)
- 4.1最小覆盖子串(困难)(滑动窗口)
- 4.2字符串的排列(中等)(滑动窗口)
- 4.3找到字符串中所有字母异位词(中等)
- 4.4※无重复字符的最长子串(滑动窗口缩减版)
- 4.5※串联所有单词的子串(困难)(以不同起点构建滑动窗口)
- 4.6长度最小的子数组(中等)(简化的滑动窗口)
- 4.7考试的最大困扰度(中等)(滑动窗口)
- 双指针题目总结
一、什么是双指针?
双指针主要用于遍历数组,两个指针指向不同的元素,从而协同完成任务。也可以延伸到多个数组的多个指针。
- 若两个指针指向同一数组,遍历方向相同且不会相交,则也称为滑动窗口(两个指针包围的区域即为当前的窗口),经常用于区间搜索。
因为可以框出一个个窗口
- 若两个指针指向同一数组,但是遍历方向相反,则可以用来进行搜索,待搜索的数组往往是排好序的,即为常见的two pointers用法。
最普通、常见的双指针,一前一后
- 之后还会介绍两个指针速度不同的用法,属于快慢指针,常用于解决链表问题。
1.1两数之和Ⅱ(简单)(直接用双指针)
已经排好序,利用双指针的思想,定一找一,一个指针从头开始,另一个指针从尾部开始,相反方向遍历整个数组,判断两个指针指向数组元素之和,=就跳出循环,>说明尾部指针大了,j–,<说明头部指针小了,i++。
class Solution {
public int[] twoSum(int[] numbers, int target) {
// 递增序列
int i = 0;
int j = numbers.length - 1;
while(i < j){
if(numbers[i] + numbers[j] == target){
break;
}else if(numbers[i] + numbers[j] > target){
j--;
}else{
i++;
}
}
return new int[] {i+1, j+1};
}
}
1.2反转字符串(简单)(原地)(直接用双指针)
一个指针指向字符串开始位置,另一个指向字符串结束位置,指针只需要走到字符串的中间位置即可
class Solution {
public void reverseString(char[] s) {
int length = s.length;
int i = 0;
int j = length - 1;
length /= 2;
while(i != length){
char tmp = s[i];
s[i] = s[j];
s[j] = tmp;
i++;
j--;
}
}
}
1.3盛最多水的容器(中等)
通过示例可以知道,水的多少=min(height[left], height[right]) * (right - left),我们每次只需让小的那一边移动即可,在移动过程中记录每次水的多少,找到其中的最大容量。
class Solution {
public int maxArea(int[] height) {
int left = 0;
int right = height.length - 1;
int ans = -1;
while (left < right) {
int area = Math.min(height[left], height[right]) * (right - left);
ans = Math.max(ans, area);
if (height[left] < height[right]) {
left++;
} else {
right--;
}
}
return ans;
}
}
1.4二分查找(本质上也是双指针)
static int binarysearch(int[] arr, int target){
int begin = 0;
int end = arr.length - 1;
while(begin <= end){
int mid = begin + (end - begin) / 2;
if(arr[mid] == target){
return mid;
}else if(arr[mid] > target){
end = mid - 1;
}else{
begin = mid + 1;
}
}
return -1;
}
1.5移除元素(简单)
按照题目要求,需要原地修改数组中元素,并返回数组长度,原地修改说明只能在原数组中进行修改,考虑使用双指针(快慢指针),快指针先遍历数组中元素,遇到不等于val的就继续遍历,慢指针负责存储不等于val的元素。
class Solution {
public int removeElement(int[] nums, int val) {
int slow = 0;
int fast = 0;
for (fast = 0; fast < nums.length; fast++) {
if (nums[fast] != val) {
nums[slow++] = nums[fast];
}
}
return slow;
}
}
1.6删除有序数组的重复元素(简单)
还是一样的快慢指针,就是要注意怎么去重,这里的去重方式和回溯专题里的一样。
class Solution {
public int removeDuplicates(int[] nums) {
// 注意是有序数组去重
int slow = 0;
int fast = 0;
for (fast = 0; fast < nums.length; fast++) {
if (fast > 0 && nums[fast] == nums[fast - 1]) {
continue;
}
nums[slow++] = nums[fast];
}
return slow;
}
}
※1.7比较含退格的字符串(简单)
这道题看似简单,但如果要求只用o(n)时间复杂度,并且o(1)空间复杂度就不那么简单了,只能考虑使用双指针,为了方便处理#,我们应该从尾部开始遍历,当是#时就统计#的个数,当不是#时就移动指针,相当于是删除元素。当不是#,且#个数为0时,就可以拿去比较。
class Solution {
public boolean backspaceCompare(String s, String t) {
int i = s.length() - 1;
int j = t.length() - 1;
int skips = 0;
int skipt = 0;
while (i >=0 || j >= 0) {
while (i >= 0) {
if (s.charAt(i) == '#') {
skips++;
i--;
} else if (skips > 0) {
skips--;
i--;
} else {
break;
}
}
while (j >= 0) {
if (t.charAt(j) == '#') {
skipt++;
j--;
} else if (skipt > 0) {
skipt--;
j--;
} else {
break;
}
}
// 注意最后为false的返回情况
if (i >= 0 && j >= 0) {
if (s.charAt(i) != t.charAt(j)) {
return false;
}
} else if (i >= 0 || j >= 0) {
return false;
}
i--;
j--;
}
return true;
}
}
※二、合并两个有序数组(简单)(三指针、逆向指针)
方法一:将两个数组直接合并再排序
方法二:三个指针,从两个数组的开始进行扫描,存入中间数组,再把中间数组的值给nums1,难点在于三指针遍历。
class Solution {
public void merge(int[] nums1, int m, int[] nums2, int n) {
// 递增排列序列,合并排序后仍然递增
// 从头部开始扫描,比较存入第三个数组
int[] nums3 = new int[m + n];
int i = 0, j = 0, pos = 0;
while(i < m && j < n){
if(nums1[i] < nums2[j]){
nums3[pos] = nums1[i];
pos++;
i++;
}else{
nums3[pos] = nums2[j];
pos++;
j++;
}
}
// 有一个数组先遍历完,但不知道是哪一个
while(i < m){
nums3[pos] = nums1[i];
i++;
pos++;
}
while(j < n){
nums3[pos] = nums2[j];
j++;
pos++;
}
for(i = 0; i < m + n; i++){
nums1[i] = nums3[i];
}
}
}
方法二:三个指针,从两个数组的结束进行扫描,逐个存入nums1的末尾位置(因为给出的nums1大小为n+m),难就难在不好想,一般指针都是从头开始扫描,不会想到从尾部开始扫描。
class Solution {
public void merge(int[] nums1, int m, int[] nums2, int n) {
// 递增排列序列,合并排序后仍然递增
// 指向nums1中的最后一个位置,从最后开始放就不会修改nums1已有的值
int pos = m + n - 1;
// 直接用m、n作为指针,从最后开始遍历
m--;
n--;
while(m >= 0 && n >= 0){
if(nums1[m] > nums2[n]){
nums1[pos] = nums1[m];
pos--;
m--;
}else{
nums1[pos] = nums2[n];
pos--;
n--;
}
}
// 总有一个数组先遍历完,如果nums2没有遍历完,说明剩下的值都小于nums1中的值,直接放完就行
// nums1的话不需要,本身就是以nums1为准
while(n >= 0){
nums1[pos] = nums2[n];
pos--;
n--;
}
}
}
三、快慢指针(简单)(环形链表、回文链表),特有题型
基本思路:
1、定义快慢指针fast和slow,起始均位于链表头部。规定fast每次后移2步,slow后移1步;
2、若fast遇到null节点,则表示链表无环,结束;
3、若链中有环,fast和slow一定会再次相遇;
4、当fast和slow相遇时,额外创建指针ptr,并指向链表头部,且每次后移1步,最终slow和ptr会在入环点相遇。
转自:https://zhuanlan.zhihu.com/p/361049436
常用于判断链表是否有环,fast指针一次移动两格,slow指针一次移动一格。如果存在环路,slow和fast指针最后一定会相遇。 是的没看错,是一定会相遇!我们只需要处理好特殊情况即可,遇到带环链表的判断,可以直接用快慢指针。
注意:没有结点,或者只有一个结点都认为是无环的
public class Solution {
public boolean hasCycle(ListNode head) {
ListNode slow = head;
ListNode fast = head;
while (fast != null && fast.next != null) {
// 快慢指针
slow = slow.next;
fast = fast.next.next;
if (slow == fast) return true;
}
return false;
}
}
3.1环形链表Ⅱ(中等)
在上一题的基础上增加了对入环第一个结点的求解,fast和slow指针相遇后,让其中一个指针从head出发,都以一步进行移动,此时再相遇即为入环第一个结点。(需要自己在纸上推导一下过程)注意上述结论成立的条件是快慢指针都从head出发!
public class Solution {
public ListNode detectCycle(ListNode head) {
ListNode fast = head;
ListNode slow = head;
boolean flag = false;
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
if (slow == fast) {
flag = true;
break;
}
}
// 有环才判断
if (flag == false) return null;
fast = head;
while (fast != slow) {
fast = fast.next;
slow = slow.next;
}
return fast;
}
}
解法二:用Hash Set,单指针一步步遍历,将每个结点存入set中,如果当前结点已经在set中,说明当前结点就是入环第一个结点,同时也判断了当前链表是否有环。
public class Solution {
public ListNode detectCycle(ListNode head) {
ListNode pos = head;
Set<ListNode> visited = new HashSet<ListNode>();
while (pos != null) {
if (visited.contains(pos)) {
return pos;
} else {
visited.add(pos);
}
pos = pos.next;
}
return null;
}
}
3.2回文链表(简单)
有了前面两道题的铺垫,会发现在普通链表中使用快慢指针可以帮助快速找到链表中点结点(slow就指向中点结点),所以这道题的思路便是,先找到链表中点结点,然后翻转后半部分链表,再用翻转后的链表与前半部分链表比较,注意处理好特殊情况,以及结点个数为奇偶时的判断情况。
解题步骤:
1、找到链表中点
2、翻转后一半链表
3、比较两半链表值
这里我们的快慢指针初始化都为head。同时注意,没有结点和只有一个结点都认为是回文链表。
1 2 3 4:slow = 2
1 2 3 4 5:slow = 3
由上面两个例子可以知道在奇偶长度下slow的位置,我们取slow的下一个结点和其之后的结点进行翻转,再与前一半结点相比较,前一半的长度一定>=后一半,所以我们以后一半翻转过后的链表长度为基准即可。
链表翻转模板:,本质上也是用到了双指针的思想,只不过这里用了三个指针prev、curr、next。
static ListNode reverseList(ListNode head) {
ListNode prev = null;
ListNode curr = head;
while (curr != null) {
// 它们之间的交换是有规律的
ListNode next = curr.next;
curr.next = prev;
prev = curr;
curr = next;
}
return prev;
}
回文链表代码:
class Solution {
public boolean isPalindrome(ListNode head) {
// 无节点、1个节点都是回文
if (head.next == null || head == null) return true;
// 先找中间节点
ListNode slow = head;
ListNode fast = head;
// 快慢指针找链表中点
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
}
// slow就是中点(偶数节点:中间靠右)
// 翻转后半部分链表
ListNode tmp = reverseList(slow);
while (tmp != null) {
if (tmp.val != head.val) return false;
tmp = tmp.next;
head = head.next;
}
return true;
}
ListNode reverseList(ListNode head) {
ListNode prev = null;
ListNode cur = head;
while (cur != null) {
ListNode next = cur.next;
cur.next = prev;
prev = cur;
cur = next;
}
return prev;
}
}
3.3删除链表的倒数第N个结点(中等)
(如果是数组,很简单可以从尾部扫描,但是链表不能够,怎么办?)
用快慢指针,快指针先走n步,然后快慢指针同时一步步走,当快指针走到null时,慢指针指向被删除结点的前一个结点。用快慢指针实现类似于对链表的尾部扫描!
图转自:https://leetcode-cn.com/problems/remove-nth-node-from-end-of-list/solution/shan-chu-lian-biao-de-dao-shu-di-nge-jie-dian-b-61/
class Solution {
public ListNode removeNthFromEnd(ListNode head, int n) {
// 用快慢指针,快指针先走n格,然后快慢指针一起一步步走,当快指针走到null时,慢指针即为要删除的
ListNode slow = head;
ListNode fast = head;
while (n != 0) {
fast = fast.next;
n--;
}
// 说明删除的是头结点
if(fast == null){
return head.next;
}
while(true){
// 保证slow指向被删除结点的前一个结点
if(fast == null || fast.next == null){
break;
}
fast = fast.next;
slow = slow.next;
}
// 删除当前结点,注意这些修改都会直接修改head代表的链表,它们只是指向了同一个链表的不同位置
slow.next = slow.next.next;
return head;
}
}
总结一下,快慢指针常常使用于链表题目中,可以用于判断环,找成环节点,找链表的中点,对于复杂的题目,找链表中点常常作为其中的基础算法部分,以此为基础构建代码。
四、※高阶用法:滑动窗口(背模板)
滑动窗口本质上也是用双指针实现的,两个指针一前一后框出了一个窗口,利用这个窗口来解决问题。
特点:滑动窗口的两个指针是同向移动,之前简单的双指针的两个指针是相对移动。
4.1最小覆盖子串(困难)(滑动窗口)
思路:
1、一前一后两个指针框出一个窗口,先让后指针移动,当窗口中包含所有目标串中的字符时(没有要求顺序,只需要统计当前窗口的字符数>=目标串中对应的字符数)停止移动后指针,此时的窗口是一个可行解。
2、开始移动前指针,往后逼近,每次移动一步判断当前窗口是否还满足条件,此时即为寻找可行解中的最优解。 当移动前指针到窗口不满足条件时,记录下上一个满足条件的窗口,停止移动前指针。
3、开始移动后指针,直到窗口满足条件,停止移动后指针,跳转到第2步,继续执行。
4、一直有一个变量存储长度最小的满足条件的窗口,返回它即可。
public class 滑动窗口 {
public static void main(String[] args) {
String s = "ADOBECODEBANC";
String t = "ABC";
// substring是取[0,1)
// System.out.println(s.substring(0, 1));
String res = minWindow(s, t);
System.out.println(res);
}
static String minWindow(String s, String t){
// 统计目标子串 t 中的词频
Map<Character, Integer> freq = new HashMap<>();
for (int i = 0; i < t.length(); i++) {
// 第一步:统计目标串中各字符个数
// 注意getOrDefault指如果没找到char就把该char个数初始化为0
freq.put(t.charAt(i), freq.getOrDefault(t.charAt(i), 0) + 1);
}
// 统计滑动窗口中的词频
Map<Character, Integer> window = new HashMap<>();
// 记录当前窗口中有多少个字符已经满足要求
int success = 0;
// 记录最小覆盖子串的起始位置和长度
int start = 0;
int len = Integer.MAX_VALUE;
// 开始滑动窗口
int left = 0;
int right = 0;
// 第二步:开始滑动窗口(先动right指针)
while(right < s.length()){
char c = s.charAt(right);
// 目标子串中包含 c
// 第三步:如果当前指向的字符在目标串中也有,加入window中
if(freq.containsKey(c)){
// 只往窗口中记录子串中需要的字符
// 把s[right]加入窗口
window.put(c, window.getOrDefault(c, 0) + 1);
// 第四步:判断window中当前字符的个数是否满足目标串要求,满足就意味着成功一次
// 成功次数是整个目标串中不同的字符
// 判断窗口中字符 c 的数量是否达到要求
if(freq.get(c).equals(window.get(c))){
success++;
}
}
// 窗口向右扩展
right++;
// 第五步,缩小window,移动左指针
// 如果窗口覆盖了 t 中的所有字符,左边指针移动缩小窗口范围
while(success == freq.size()){
// 更新结果(只要最小的覆盖子串)
if(right - left < len){
start = left;
// 因为使用substring所以不用+1
len = right - left;
}
// 缩小窗口范围
char tmp = s.charAt(left);
// 如果子串中不包含tmp那就没有保留的必要
if(freq.containsKey(tmp)){
// 发现窗口中的该字符数和子串中的需求个数一样
// 还是让它左移,跳出循环,让右指针再移动去找其它可行解
if(window.get(tmp).equals(freq.get(tmp))){
success--;
}
// 缩小了window,里面对应的字符个数-1
window.put(tmp, window.get(tmp) - 1);
}
left++;
}
}
return len == Integer.MAX_VALUE ? "" : s.substring(start, start + len);
}
}
可以看到上面两部分是对称的,这类题型的模板可以总结如下:
参考自:https://labuladong.github.io/algo/1/11/
static void slidingWindow(String s, String t){
int[] need = new int[128];
int[] have = new int[128];
// 统计目标子串各字符个数
for (int i = 0; i < t.length(); i++) {
need[t.charAt(i)]++;
}
int left = 0, right = 0, count = 0;
// 开始滑动窗口
while(right < s.length()){
// 右移窗口
char c = s.charAt(right);
right++;
// 对窗口内数据进行需要的更新
...
// 判断窗口是否可以从左边开始收缩(具体条件根据题意来)
while(window needs shrink){
// 记录要移出去的字符
char d = s.charAt(left);
left++;
// 对窗口内数据进行需要的更新
...
}
}
}
如果不喜欢用HashMap也可以用Int数组进行模拟,代码如下:
用Int数组模拟速度要快于HashMap,但适用性不高,数组有其局限性,建议用统一的框架模板去写题,也方便自己更加熟悉框架。
class Solution {
public String minWindow(String s, String t) {
int[] need = new int[128];
int[] have = new int[128];
// 统计目标串中的字符个数
for (int i = 0; i < t.length(); i++) {
need[t.charAt(i)]++;
}
int left = 0, right = 0, len = Integer.MAX_VALUE, start = 0, count = 0;
// 开始滑动窗口
while(right < s.length()){
// 右指针右移扩大窗口
char c = s.charAt(right);
right++;
// 目标串需要右移到的字符,加入到window中
if(need[c] != 0){
have[c]++;
// 统计当前窗口中满足目标串中字符的个数
if(have[c] <= need[c]){
count++;
}
}
// 左指针往右缩小窗口
while(count == t.length()){
// 更新结果
if(right - left < len){
len = right - left;
start = left;
}
c = s.charAt(left);
left++;
// 目标串需要左移掉的字符
if(need[c] != 0){
if(have[c] == need[c]){
count--;
// 减掉了,count != t.length(),这个循环要进来必须要相等
}
// 窗口中拥有的字符对应--
have[c]--;
}
}
}
return len == Integer.MAX_VALUE ? "" : s.substring(start, start + len);
}
}
使用HashMap和Int数组模拟的区别:
HashMap:
Int数组:
int数组是统计了满足目标串中所有字符的个数,而HashMap统计的是种类!
搞懂了滑动窗口后,下面练习几道经典题目
4.2字符串的排列(中等)(滑动窗口)
上面一道题只需要窗口中的字符个数满足即可,没有要求字符要连续,本题中需要是子串的排列,子串中的各字符一定是连接在一起的,不能间隔开,怎么办呢?
思路:还是用上一题的模板,当窗口中的字符数满足要求,且长度也等于目标串的长度时,那么此时窗口中的串一定就是目标串的排列。
class Solution {
public boolean checkInclusion(String s1, String s2) {
// s2中必须包含s1的子串,而不是上一题(只需要包含字符就行)
// s2 = eidbha s1 = ab就不行,因为bha被隔开了
int[] need = new int[128];
int[] have = new int[128];
for (int i = 0; i < s1.length(); i++) {
need[s1.charAt(i)]++;
}
int left = 0, right = 0, cnt = 0, len = Integer.MAX_VALUE;
// 开始滑动窗口
while(right < s2.length()){
char c = s2.charAt(right);
right++;
if(need[c] != 0){
// 窗口元素更新
have[c]++;
if(have[c] <= need[c]){
cnt++;
}
}
// 缩小窗口
while(cnt == s1.length()){
// 这里不需要再记录最短长度,我们只用比较每次窗口中的长度是否等于目标串的长度
len = right - left;
// 如果结果的长度恰好 = 目标串的长度
if(len == s1.length()){
return true;
}
c = s2.charAt(left);
left++;
if(need[c] != 0){
if(have[c] == need[c]) {
cnt--;
}
have[c]--;
}
}
}
return false;
}
}
4.3找到字符串中所有字母异位词(中等)
本质和上题一样,也是找目标串的排列,只不过本题需要记录下source串中target串排列的开始的index。
class Solution {
public List<Integer> findAnagrams(String s, String p) {
// 这道题和上题一样是找子串的排列,只不过这次需要记录下在source中的开始索引
// 还是先按模板写出滑动窗口的模板
int[] need = new int[128];
int[] have = new int[128];
// 用于存放答案(source串中target串的开始index)
List<Integer> ans = new ArrayList<>();
// 统计目标串中各字符数
for (int i = 0; i < p.length(); i++) {
need[p.charAt(i)]++;
}
int left = 0, right = 0, cnt = 0, len = Integer.MAX_VALUE;
// 开始滑动窗口
while(right < s.length()){
// 右指针右移扩大窗口
char c = s.charAt(right);
right++;
// 更新窗口中字符
if(need[c] != 0){
have[c]++;
if(have[c] <= need[c]){
// 记录满足目标串字符个数
cnt++;
}
}
// 左指针右移缩小窗口
// 只是说当前窗口中的字符个数满足目标串要求,但字符顺序不能保证
while(cnt == p.length()){
// 更新结果
len = right - left;
if(len == p.length()){
ans.add(left);
}
c = s.charAt(left);
left++;
// 更新窗口中字符
if(need[c] != 0){
if(have[c] == need[c]){
cnt--;
}
have[c]--;
}
}
}
return ans;
}
}
要注意窗口中两个长度的含义,cnt统计的是当前窗口中满足目标串的字符数(不是种类,是数量,如果用HashMap统计的就是种类),而len(right - left),是当前窗口的长度,len可能大于cnt,代表窗口中包含了多余的(不是目标串中字符)字符,存在可能的压缩机会。而当len=cnt时,代表窗口中的字符串是目标串的排列。
※注意,滑动窗口不能保证窗口中的字符串一定按照目标串中字符的顺序,只能保证是目标串的排列,如果题目要求一定要和目标串顺序一致,无间隔,那就直接用substring。
4.4※无重复字符的最长子串(滑动窗口缩减版)
这道题其实要比之前的题简单,只有一个source字符串,没有目标字符串,只让找无重复字符的最长子串,所以我们只需要记录窗口内各字符的个数即可,窗口内各字符个数一定要<=1。还是先右指针移动扩展窗口,当右移指针后包括进来后,窗口中该字符的个数==2,说明有重复,再往后走没有意义了,这个时候就可以开始右移左指针缩小窗口,当缩小过后的窗口没有重复字符了就继续扩展窗口。(注意答案的更新位置)
class Solution {
public int lengthOfLongestSubstring(String s) {
int right = 0, left = 0, len = 0;
int[] have = new int[128];
while(right < s.length()){
char c = s.charAt(right);
right++;
have[c]++;
// 当前窗口中已经有重复的,停止右移,开始缩小窗口(确定什么条件才缩小窗口)
while(have[c] == 2){
// 缩小了窗口就需要更新窗口内数据
char d = s.charAt(left);
left++;
have[d]--;
}
// 注意答案更新位置
len = Math.max(len, right - left);
}
return len;
}
}
4.5※串联所有单词的子串(困难)(以不同起点构建滑动窗口)
本题相当于是在目标串中找几个单词的排列(必须按一个个单词排列,并且单词间不能有间隔,连续),之前滑动窗口的题目一直都是以一个个字符为单位进行考虑(没有考虑顺序),这里要求了单词必须有序,该怎么办? 我们可以以一个个单词为单位来扩展、缩小窗口,这样就保障了单词的连续性。
可以先按照滑动窗口的模板写出下面代码:
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class Main {
public static void main(String[] args) {
String s = "barfoothefoobarman";
String[] words = {"foo", "bar"};
System.out.println(findSubstring(s, words));
}
static List<Integer> findSubstring(String s, String[] words) {
List<Integer> ans = new ArrayList<>();
// 一个记录words需要的情况,一个记录window中已有的情况
Map<String, Integer> need = new HashMap<>();
int words_len = 0;
// 以单词为单位
for(String str : words){
need.put(str, need.getOrDefault(str, 0) + 1);
// 可能有重复单词的情况,所以得统计words_len
words_len += str.length();
}
// 每个单词的长度是一致的
int len = words[0].length();
int left = 0, cnt = 0, right = 0;
Map<String, Integer> have = new HashMap<>();
// 以单词的长度为单位滑动窗口
while(right + len <= s.length()){
// 注意,本题中的比较单位是一个个word
String tmp = s.substring(right, right + len);
// 按照单词长度扩展窗口
right += len;
// 是需要的word(注意word需要保持字符顺序)
if (need.containsKey(tmp)) {
// 加入window中
have.put(tmp, have.getOrDefault(tmp, 0) + 1);
// 看当前单词是否满足需求(整个单词类别满足需求),满足了需求就cnt++
if(need.get(tmp).equals(have.get(tmp))) {
cnt++;
}
}
while(cnt == need.size()) {
// 在满足目标串需求的情况下长度也一致
if(right - left == words_len){
ans.add(left);
}
tmp = s.substring(left, left + len);
// 按照单词大小缩小窗口
left += len;
// 当前移动的单词正好需要
if(need.containsKey(tmp)) {
if(need.get(tmp).equals(have.get(tmp))) {
cnt--;
}
// 因为window中只存了need中需要的word
have.put(tmp, have.get(tmp) - 1);
}
}
}
return ans;
}
}
上面的代码肯定是不对的,为什么呢?我们只考虑了从source串中第0个字符开始构建滑动窗口,并且不管扩展、缩小窗口都是以一个word的长度为单位,导致有些情况被忽略了,如下图所示:
如何解决?可以考虑在第0号位置采样后,再在后面紧接着的位置进行采样,就可以保证source串中的所有情况被遍历,并且再采样word.length次就可以保证。
如上图可知,当采样word.length次后,就可以保证所有的情况被考虑。最后还要考虑words中还有可能有单词重复的情况。
最终实现代码:大体的形式还是按照之前滑动窗口的模板,只是细节、构建窗口的次数进行了调整。
class Solution {
public List<Integer> findSubstring(String s, String[] words) {
List<Integer> ans = new ArrayList<>();
// 一个记录words需要的情况,一个记录window中已有的情况
Map<String, Integer> need = new HashMap<>();
int words_len = 0;
// 以单词为单位
for(String str : words){
need.put(str, need.getOrDefault(str, 0) + 1);
// 可能有重复单词的情况,所以得统计words_len
words_len += str.length();
}
// 每个单词的长度是一致的
int len = words[0].length();
// 前面的for循环是为了保证能够遍历所有的子串情况
// 以0 - 单词长度-1 为开始索引分别构建滑动窗口
for (int i = 0; i < len; i++) {
int left = i, cnt = 0, right = i;
Map<String, Integer> have = new HashMap<>();
while(right + len <= s.length()){
// 注意,本题中的比较单位是一个个word
String tmp = s.substring(right, right + len);
right += len;
// 是需要的word(注意word需要保持字符顺序)
if (need.containsKey(tmp)) {
// 加入window中
have.put(tmp, have.getOrDefault(tmp, 0) + 1);
// 看当前单词是否满足需求(整个单词类别满足需求),满足了需求就cnt++
if(need.get(tmp).equals(have.get(tmp))) {
cnt++;
}
}
while(cnt == need.size()) {
// 在满足目标串需求的情况下长度也一致
if(right - left == words_len){
ans.add(left);
}
tmp = s.substring(left, left + len);
// 当前移动的单词正好需要
if(need.containsKey(tmp)) {
if(need.get(tmp).equals(have.get(tmp))) {
cnt--;
}
// 因为window中只存了need中需要的word
have.put(tmp, have.get(tmp) - 1);
}
left += len;
}
}
}
return ans;
}
}
最后,关于此题再说几句,这道题关键是保证滑动窗口遍历了所有可能情况,这点一直不知道如何解决,通过画图才明白,对source串采样单个word长度的次数就可以(每个单词长度一致)。
4.6长度最小的子数组(中等)(简化的滑动窗口)
class Solution {
public int minSubArrayLen(int target, int[] nums) {
int left = 0, right = 0, len = Integer.MAX_VALUE, sum = 0;
while (right < nums.length) {
sum += nums[right];
right++;
while (sum >= target) {
if (right - left < len) {
len = right - left;
}
sum -= nums[left];
left++;
}
}
return len == Integer.MAX_VALUE ? 0 : len;
}
}
4.7考试的最大困扰度(中等)(滑动窗口)
本来还在思考DP,发现没思路,一看居然是滑动窗口。
class Solution {
public int maxConsecutiveAnswers(String answerKey, int k) {
return Math.max(getLen(answerKey, 'T', k), getLen(answerKey, 'F', k));
}
// 可以分为替换T为F,和替换F为T两种情况,
// 以替换T为F为例,也就是找当前窗口中T的个数(而F就不用管,因为是将T替换为F)
// 因为只能替换k次,所以一旦T的个数大于k了,就得开始缩小窗口
// 直到窗口中的T的个数等于k了就可以求最值了
static int getLen(String str, char c, int k) {
int i = 0, j = 0;
int n = str.length();
int have = 0;
int ans = 1;
// 滑动窗口,两个指针同向往后移动框出一个窗口
while (j < n) {
if (str.charAt(j) == c) {
have++;
}
j++;
// 缩小窗口
while (have > k) {
if (str.charAt(i) == c) {
have--;
}
// 不管左边是不是c都要移动左指针
i++;
}
// j最后还会加到n,所以结果只用统计j - i,不用+1
ans = Math.max(ans, j - i);
}
return ans;
}
}
双指针题目总结
双指针可以大致分为快慢指针和普通双指针(三指针),快慢指针多用于解决链表问题(有环链表、回文链表、删除链表倒数结点等),而普通双指针多用于解决数组问题(字符串问题、二分)。
滑动窗口,主要解决字符串中子串、子串的排列等问题(步骤):
1、统计目标串中的字符个数
2、开始滑动窗口
3、右指针先移动,同时更新窗口内数据
4、当窗口内的字符数==目标串的字符数,左指针开始移动,缩小窗口
5、缩小窗口的同时更新窗口内数据(大部分题目都是在缩小窗口时进行的操作或记录的结果不同,其余都是一致的)
滑动窗口的维护,都是定一移一,先让一个指针动,再让另一个指针动,同一时间只有一个指针动,指针什么时候动要根据题目意思来,一般是右指针先动(扩展窗口),然后满足某个条件后再让左指针动(缩小窗口)
遇到链表类问题可以往快慢指针想,遇到数组、字符串问题可以往双指针(滑动窗口解决子串匹配问题)想。
区别双指针和滑动窗口,双指针是一前一后,而滑动窗口是同向移动,都是从最左边开始往右边移动,滑动窗口可以构造一个区间,来满足题目意思,或者利用这个区间干其他的事。