目录
变式十三:允许替换字符以满足可分割成k个回文子串,其所需要修改替换次数
一.工具汇总
双指针技巧主要分为两类:左右指针和快慢指针。
所谓左右指针,就是两个指针相向而行或者相背而行;
而所谓快慢指针,就是两个指针同向而行,一快一慢。
在数组中并没有真正意义上的指针,但我们可以把索引当做数组中的指针
这样也可以在数组中施展双指针技巧。
二.快慢指针
1.合并排序数组
将按升序排序的整数数组A和B合并,新数组也需有序。
#include <iostream>
using namespace std;
int main()
{
int a[1000];
int b[1000];
int m,n;
cin>>m;
for(int i=0; i<m; i++)
cin>>a[i];
cin>>n;
for(int i=0; i<n; i++)
cin>>b[i];
int left,right;
left = right = 0;
int ret[2000];
int i = 0;
while(left<m && right<n)
{
if(a[left]<=b[right])
ret[i++] = a[left++];
else
ret[i++] = b[right++];
}
while(left<m)
ret[i++] = a[left++];
while(right<n)
ret[i++] = b[right++];
for(int i = 0; i<m+n; i++)
printf("ret[%d]:%d \n",i,ret[i]);
return 0;
}
此题与归并排序中的并有异曲同工之妙!
变式一:
合并两个排序的整数数组 A
和 B
变成一个新的数组。
原地修改数组 A
把数组 B
合并到数组 A
的后面。
#include <iostream>
using namespace std;
void Insert(int x,int arr[],int n,int p)
{
for(int i=n-1; i>=p; i--)
arr[i+1] = arr[i];
arr[p] = x;
}
int main()
{
int a[1000];
int b[1000];
int m,n;
cin>>m;
for(int i=0; i<m; i++)
cin>>a[i];
cin>>n;
for(int i=0; i<n; i++)
cin>>b[i];
int left,right;
left = right = 0;
while(left<m+right && right<n)
{
if(a[left]>=b[right])
{
Insert(b[right],a,(m+right),left);
right++;
left++;
}
else
left++;
}
while(right<n)
a[left++] = b[right++];
for(int i = 0; i<m+n; i++)
printf("a[%d]:%d \n",i,a[i]);
return 0;
}
插入即可,值得注意的点是关于while里面的条件的写法
left < m+right ,这里要比较的是更新后的a数组的长度也就是 m+right 哈哈
变式二:合并两个排序链表
将两个排序(升序)链表合并为一个新的升序排序链表
#include <iostream>
using namespace std;
typedef struct Node
{
int data;
struct Node *next;
} Node;
typedef struct List
{
int length;
Node *head;
} List;
List Init(void)
{
List l;
l.head = NULL;
l.length = 0;
return l;
}
bool Insert(List& l,int x,int p)
{
if(l.length<p)
return false;
Node* ptr = l.head;
Node* node = (Node*)malloc(sizeof(Node));
node->data = x;
node->next = NULL;
if(l.head==NULL)
{
l.head = node;
l.length++;
return true;
}
while(--p)
{
ptr = ptr->next;
}
Node* ptrNext = ptr->next;
ptr->next = node;
node->next = ptrNext;
l.length++;
return true;
}
int main()
{
List la = Init();
List lb = Init();
int m,n;
int temp;
cin>>m;
for(int i=0; i<m; i++)
{
cin>>temp;
Insert(la,temp,la.length);
}
cin>>n;
for(int i=0; i<n; i++)
{
cin>>temp;
Insert(lb,temp,lb.length);
}
Node* left;
Node* right;
left = la.head;
right = lb.head;
List ret = Init();
while(left != NULL && right != NULL)
{
if(left->data <= right->data)
{
Insert(ret,left->data,ret.length);
left = left->next;
}
else
{
Insert(ret,right->data,ret.length);
right = right->next;
}
}
while(left != NULL)
{
Insert(ret,left->data,ret.length);
left = left->next;
}
while(right != NULL)
{
Insert(ret,right->data,ret.length);
right = right->next;
}
Node *ptr = ret.head;
printf("List[%d]:",m+n);
for(int i = 0; i<ret.length; i++)
{
printf("%d[%d] ->",ptr->data,i);
ptr = ptr->next;
}
printf("null\n");
return 0;
}
这里使用 dummy 虚拟头结点,就不需要分类讨论去实现头结点的更新了
上面的各种复杂的函数,其实就是实现链表的初始化与输入,与本题核心内容无关。。。
ListNode* mergeTwoLists(ListNode* l1, ListNode* l2) {
// 虚拟头结点
ListNode* dummy = new ListNode(-1);
*p = dummy;
ListNode* p1 = l1, *p2 = l2;
while (p1 != NULL && p2 != NULL) {
// 比较 p1 和 p2 两个指针
// 将值较小的的节点接到 p 指针
if (p1->val > p2->val) {
p->next = p2;
p2 = p2->next;
}
else {
p->next = p1;
p1 = p1->next;
}
// p 指针不断前进
p = p->next;
}
if (p1 != NULL) {
p->next = p1;
}
if (p2 != NULL) {
p->next = p2;
}
return dummy->next;//这里注意要返回dummy的下一个节点
}
我们的 while 循环每次比较 p1
和 p2
的大小,把较小的节点接到结果链表上
代码中还用到一个链表的算法题中是很常见的「虚拟头结点」技巧,也就是 dummy
节点。
你可以试试,如果不使用 dummy
虚拟节点,代码会复杂一些,需要额外处理指针 p
为空的情况。而有了 dummy
节点这个占位符,可以避免处理空指针的情况,降低代码的复杂。
Tips:
什么时候需要用虚拟头结点?
这里总结下:当你需要创造一条新链表的时候,可以使用虚拟头结点简化边界情况的处理。
比如说,让你把两条有序链表合并成一条新的有序链表,是不是要创造一条新链表?
再比你想把一条链表分解成两条链表,是不是也在创造新链表?
这些情况都可以使用虚拟头结点简化边界情况的处理。
总之用了总没错哈哈
变式三:合并两个排序的间隔列表
合并两个已排序的区间列表,并将其作为一个新的有序区间列表返回。
新的区间列表应该通过拼接两个列表的区间并按升序排序。
同一个列表中的区间一定不会重叠。
不同列表中的区间可能会重叠。
输入: list1 = [(1,2),(3,4)] and list2 = [(2,3),(5,6)]
输出: [(1,4),(5,6)]
解释:
(1,2),(2,3),(3,4) --> (1,4)
(5,6) --> (5,6)
#include <iostream>
using namespace std;
int main()
{
int m,n;
int a[1000][2] = {0};
int b[1000][2] = {0};
cin>>m;
for(int i=0; i<m; i++)
{
cin>>a[i][0]>>a[i][1];
}
cin>>n;
for(int i=0; i<n; i++)
{
cin>>b[i][0]>>b[i][1];
}
int left,right;
left = right = 0;
int ret[2000][2];
int i = 0;
while(left<m && right<n)
{
if(a[left][1] >= b[right][0])
{
ret[i][0] = a[left][0];
ret[i][1] = b[right][1];
right++;
left++;
}
else
left++;
}
while(left<m)
{
ret[i][0] = a[left][0];
ret[i][1] = a[left][1];
i++;
left++;
}
while(right<n)
{
ret[i][0] = b[right][0];
ret[i][1] = a[right][1];
i++;
right++;
}
for(int i=0; i<m+n; i++)
printf("(%d ,%d)\n",ret[i][0],ret[i][1]);
return 0;
}
变式四:单链表的分解
给你一个链表的头节点 head 和一个特定值 x ,请你对链表进行分隔,使得所有 小于 x 的节点都
出现在 大于或等于 x 的节点之前。
你应当 保留 两个分区中每个节点的初始相对位置。
输入:head = [1,4,3,2,5,2], x = 3 输出:[1,2,2,4,3,5]
在合并两个有序链表时让你合二为一,而这里需要分解让你把原链表一分为二。
具体来说,我们可以把原链表分成两个小链表,一个链表中的元素大小都小于 x
另一个链表中的元素都大于等于 x
,最后再把这两条链表接到一起,就得到了题目想要的结果。
整体逻辑和合并有序链表非常相似,细节直接看代码吧,注意虚拟头结点的运用:
ListNode* partition(ListNode* head, int x) {
// 存放小于 x 的链表的虚拟头结点
ListNode* dummy1 = new ListNode(-1);
// 存放大于等于 x 的链表的虚拟头结点
ListNode* dummy2 = new ListNode(-1);
// p1, p2 指针负责生成结果链表
ListNode* p1 = dummy1, * p2 = dummy2;
// p 负责遍历原链表,类似合并两个有序链表的逻辑
// 这里是将一个链表分解成两个链表
ListNode* p = head;
while (p != nullptr) {
if (p->val >= x) {
p2->next = p;
p2 = p2->next;
} else {
p1->next = p;
p1 = p1->next;
}
// 断开原链表中的每个节点的 next 指针
ListNode* temp = p->next;
p->next = nullptr;
p = temp;
}
// 连接两个链表
p1->next = dummy2->next;
return dummy1->next;
}
变式五.合并k个有序链表
给你一个链表数组,每个链表都已经按升序排列。
请你将所有链表合并到一个升序链表中,返回合并后的链表。
输入:lists = [[1,4,5],[1,3,4],[2,6]]
输出:[1,1,2,3,4,4,5,6]
解释:链表数组如下:
[ 1->4->5,
1->3->4,
2->6 ]
将它们合并到一个有序链表中得到。
1->1->2->3->4->4->5->6
合并 k
个有序链表的逻辑类似合并两个有序链表
难点在于,如何快速得到 k
个节点中的最小节点,接到结果链表上?
这里我们就要用到 优先级队列(二叉堆) 这种数据结构
把链表节点放入一个最小堆,就可以每次获得 k
个节点中的最小节点:
ListNode* mergeKLists(vector<ListNode*>& lists) {
if (lists.empty()) return nullptr;
// 虚拟头结点
ListNode* dummy = new ListNode(-1);
ListNode* p = dummy;
// 优先级队列,最小堆
priority_queue<ListNode*, vector<ListNode*>, function<bool(ListNode*, ListNode*)>>
pq([] (ListNode* a, ListNode* b)
{ return a->val > b->val; });
// 将 k 个链表的头结点加入最小堆
for (auto head : lists) {
if (head != nullptr) {
pq.push(head);
}
}
while (!pq.empty()) {
// 获取最小节点,接到结果链表中
ListNode* node = pq.top();
pq.pop();
p->next = node;
if (node->next != nullptr) {
pq.push(node->next);
}
// p 指针不断前进
p = p->next;
}
return dummy->next;
}
它的时间复杂度是多少呢?
优先队列 pq
中的元素个数最多是 k
,所以一次 poll
或者 add
方法的时间复杂度是 O(logk)
;
所有的链表节点都会被加入和弹出 pq
,所以算法整体的时间复杂度是 O(Nlogk)
,其中 k
是链表的条数,N
是这些链表的节点总数。
2.单链表的倒数第 k 个节点
从前往后寻找单链表的第 k
个节点很简单,一个 for 循环遍历过去就找到了
但是如何寻找从后往前数的第 k
个节点呢?
那你可能说,假设链表有 n
个节点,倒数第 k
个节点就是正数第 n - k + 1
个节点,不也是一个 for 循环的事儿吗?
是的,但是算法题一般只给你一个 ListNode
头结点代表一条单链表
你不能直接得出这条链表的长度 n
,而需要先遍历一遍链表算出 n
的值,然后再遍历链表计算第 n - k + 1
个节点。
也就是说,这个解法需要遍历两次链表才能得到出倒数第 k
个节点。
那么,我们能不能只遍历一次链表,就算出倒数第 k
个节点?
思路如下:
首先,我们先让一个指针
p1
指向链表的头节点head
,然后走k
步现在的
p1
,只要再走n - k
步,就能走到链表末尾的空指针了对吧?趁这个时候,再用一个指针
p2
指向链表头节点head
接下来就很显然了,让
p1
和p2
同时向前走,p1
走到链表末尾的空指针时前进了n - k
步,p2
也从head
开始前进了n - k
步,停留在第n - k + 1
个节点上,即恰好停链表的倒数第k
个节点上这样,只遍历了一次链表,就获得了倒数第
k
个节点p2
// 返回链表的倒数第 k 个节点
ListNode* findFromEnd(ListNode* head, int k) {
ListNode* p1 = head;
// p1 先走 k 步
for (int i = 0; i < k; i++) {
p1 = p1 -> next;
}
ListNode* p2 = head;
// p1 和 p2 同时走 n - k 步
while (p1 != nullptr) {
p2 = p2 -> next;
p1 = p1 -> next;
}
// p2 现在指向第 n - k + 1 个节点,即倒数第 k 个节点
return p2;
}
变式一: 删除链表的倒数第n
个结点
给你一个链表,删除链表的倒数第 n
个结点,并且返回链表的头结点
ListNode* removeNthFromEnd(ListNode* head, int n) {
ListNode* dummy = new ListNode(-1);
dummy->next = head;
ListNode* x = findFromEnd(dummy, n + 1);
x->next = x->next->next;
return dummy->next;
}
// 返回链表的倒数第 k 个节点
ListNode* findFromEnd(ListNode* head, int k) {
ListNode* p1 = head;
// p1 先走 k 步
for (int i = 0; i < k; i++) {
p1 = p1 -> next;
}
ListNode* p2 = head;
// p1 和 p2 同时走 n - k 步
while (p1 != nullptr) {
p2 = p2 -> next;
p1 = p1 -> next;
}
// p2 现在指向第 n - k + 1 个节点,即倒数第 k 个节点
return p2;
}
这个逻辑就很简单了,要删除倒数第 n
个节点,就得获得倒数第 n + 1
个节点的引用,可以用我们实现的 findFromEnd
来操作。
不过注意我们又使用了虚拟头结点的技巧,也是为了防止出现空指针的情况,比如说链表总共有 5 个节点,题目就让你删除倒数第 5 个节点,也就是第一个节点,那按照算法逻辑,应该首先找到倒数第 6 个节点。
但第一个节点前面已经没有节点了,这就会出错。
但有了我们虚拟节点 dummy
的存在,就避免了这个问题,能够对这种情况进行正确的删除。
3.单链表的中点
问题的关键也在于我们无法直接得到单链表的长度 n
,常规方法也是先遍历链表计算 n
,再遍历一次得到第 n / 2
个节点,也就是中间节点。
如果想一次遍历就得到中间节点,也需要耍点小聪明,使用「快慢指针」的技巧:
我们让两个指针 slow
和 fast
分别指向链表头结点 head
。
每当慢指针 slow
前进一步,快指针 fast
就前进两步
这样,当 fast
走到链表末尾时,slow
就指向了链表中点。
ListNode* middleNode(ListNode* head) {
// 快慢指针初始化指向 head
ListNode* slow = head;
ListNode* fast = head;
// 快指针走到末尾时停止
while (fast != nullptr && fast->next != nullptr) {
// 慢指针走一步,快指针走两步
slow = slow->next;
fast = fast->next->next;
}
// 慢指针指向中点
return slow;
}
需要注意的是,如果链表长度为偶数,也就是说中点有两个的时候,我们这个解法返回的节点是靠后的那个节点。
变式一:判断链表中是否有环
判断链表是否包含环属于经典问题了,解决方案也是用快慢指针:
每当慢指针 slow
前进一步,快指针 fast
就前进两步。
如果 fast
最终遇到空指针,说明链表中没有环;
如果 fast
最终和 slow
相遇,那肯定是 fast
超过了 slow
一圈,说明链表中含有环。
只需要把寻找链表中点的代码稍加修改就行了:
bool hasCycle(ListNode* head) {
// 初始化快慢指针,指向头结点
ListNode* slow = head;
ListNode* fast = head;
// 快指针到尾部时停止
while (fast && fast->next) {
// 慢指针走一步,快指针走两步
slow = slow->next;
fast = fast->next->next;
// 快慢指针相遇,说明含有环
if (slow == fast) {
return true;
}
}
// 不包含环
return false;
}
变式二:环的起点
ListNode* detectCycle(ListNode* head) {
ListNode* fast = head;
ListNode* slow = head;
while (fast != nullptr && fast->next != nullptr) {
fast = fast->next->next;
slow = slow->next;
if (fast == slow) break;
}
if (fast == nullptr || fast->next == nullptr) {
// fast 遇到空指针说明没有环
return nullptr;
}
// 重新指向头结点
slow = head;
// 快慢指针同步前进,相交点就是环起点
while (slow != fast) {
fast = fast->next;
slow = slow->next;
}
return slow;
}
可以看到,当快慢指针相遇时,让其中任一个指针指向头节点,然后让它俩以相同速度前进,再次相遇时所在的节点位置就是环开始的位置。
为什么要这样呢?这里简单说一下其中的原理。
我们假设快慢指针相遇时,慢指针 slow
走了 k
步,那么快指针 fast
一定走了 2k
步
fast
一定比 slow
多走了 k
步,这多走的 k
步其实就是 fast
指针在环里转圈圈
所以 k
的值就是环长度的「整数倍」
假设相遇点距环的起点的距离为 m
,那么结合上图的 slow
指针,环的起点距头结点 head
的距离为 k - m
,也就是说如果从 head
前进 k - m
步就能到达环起点。
巧的是,如果从相遇点继续前进 k - m
步,也恰好到达环起点。
因为结合上图的 fast
指针,从相遇点开始走k步可以转回到相遇点,那走 k - m
步肯定就走到环起点了
意思是,先让fast在相遇点走k步,转了很多个圈回到原点,让slow从head开始走k步,走到了相遇点,两个再同时往回倒退m步,就到了环开始的位置,也就是可以在这里先相遇!!!
所以,只要我们把快慢指针中的任一个重新指向 head
,然后两个指针同速前进,k - m
步后一定会相遇,相遇之处就是环的起点了。
变式三:相交链表
给你两个单链表的头节点 headA 和 headB ,请你找出并返回两个单链表相交的起始节点。
如果两个链表不存在相交节点,返回 null 。
图示两个链表在节点 c1 开始相交:
题目数据 保证 整个链式结构中不存在环。
注意,函数返回结果后,链表必须 保持其原始结构 。
这个题直接的想法可能是用 HashSet
记录一个链表的所有节点,然后和另一条链表对比,但这就需要额外的空间。
如果不用额外的空间,只使用两个指针,你如何做呢?
难点在于,由于两条链表的长度可能不同,两条链表之间的节点无法对应:
如果用两个指针 p1
和 p2
分别在两条链表上前进,并不能同时走到公共节点,也就无法得到相交节点 c1
。
解决这个问题的关键是,通过某些方式,让 p1
和 p2
能够同时到达相交节点 c1
。
所以,我们可以让 p1
遍历完链表 A
之后开始遍历链表 B
让 p2
遍历完链表 B
之后开始遍历链表 A
,这样相当于「逻辑上」两条链表接在了一起。
如果这样进行拼接,就可以让 p1
和 p2
同时进入公共部分,也就是同时到达相交节点 c1
那你可能会问,如果说两个链表没有相交点,是否能够正确的返回 null 呢?
这个逻辑可以覆盖这种情况的,相当于 c1
节点是 null 空指针嘛,可以正确返回 null。
按照这个思路,可以写出如下代码:
// 求链表的交点
ListNode* getIntersectionNode(ListNode* headA, ListNode* headB) {
// p1 指向 A 链表头结点,p2 指向 B 链表头结点
ListNode *p1 = headA, *p2 = headB;
while (p1 != p2) {
// p1 走一步,如果走到 A 链表末尾,转到 B 链表
if (p1 == nullptr)
p1 = headB;
else
p1 = p1->next;
// p2 走一步,如果走到 B 链表末尾,转到 A 链表
if (p2 == nullptr)
p2 = headA;
else
p2 = p2->next;
}
return p1; // 返回交点
}
如果把两条链表首尾相连,那么「寻找两条链表的交点」的问题转换成了前面讲的「寻找环起点」的问题
不得不说这是一个很巧妙的转换!不过需要注意的是,这道题说不让你改变原始链表的结构,所以你把题目输入的链表转化成环形链表求解之后记得还要改回来,否则无法通过。
另外,既然「寻找两条链表的交点」的核心在于让 p1
和 p2
两个指针能够同时到达相交节点 c1
,那么可以通过预先计算两条链表的长度来做到这一点,具体代码如下:
ListNode* getIntersectionNode(ListNode* headA, ListNode* headB) {
int lenA = 0, lenB = 0;
// 计算两条链表的长度
for (ListNode *p1 = headA; p1 != nullptr; p1 = p1->next) {
lenA++;
}
for (ListNode *p2 = headB; p2 != nullptr; p2 = p2->next) {
lenB++;
}
// 让 p1 和 p2 到达尾部的距离相同
ListNode *p1 = headA, *p2 = headB;
if (lenA > lenB) {
// p1 先走过 lenA-lenB 步
for (int i = 0; i < lenA - lenB; i++) {
p1 = p1->next;
}
} else {
// p2 先走过 lenB-lenA 步
for (int i = 0; i < lenB - lenA; i++) {
p2 = p2->next;
}
}
// 看两个指针是否会相同,p1 == p2 时有两种情况:
// 1、要么是两条链表不相交,他俩同时走到尾部空指针
// 2、要么是两条链表相交,他俩走到两条链表的相交点
while (p1 != p2) {
p1 = p1->next;
p2 = p2->next;
}
return p1;
}
4.删除数组中重复出现的元素
给你一个 升序排列 的数组 nums ,请你 原地 删除重复出现的元素,使每个元素 只出现一次 ,返回删除后数组的新长度。元素的 相对顺序 应该保持 一致 。
由于在某些语言中不能改变数组的长度,所以必须将结果放在数组nums的第一部分。
更规范地说,如果在删除重复项之后有 k 个元素,那么 nums 的前 k 个元素应该保存最终结果。
将最终结果插入 nums 的前 k 个位置后返回 k 。
不要使用额外的空间,你必须在 原地 修改输入数组 并在使用 O(1) 额外空间的条件下完成。
简单解释一下什么是原地修改:
如果不是原地修改的话,我们直接 new 一个
int[]
数组,把去重之后的元素放进这个新数组中,然后返回这个新数组即可。但是现在题目让你原地删除,不允许 new 新数组,只能在原数组上操作,然后返回一个长度,这样就可以通过返回的长度和原始数组得到我们去重后的元素有哪些了。
由于数组已经排序,所以重复的元素一定连在一起,找出它们并不难。
但如果毎找到一个重复元素就立即原地删除它,由于数组中删除元素涉及数据搬移,整个时间复杂度是会达到
O(N^2)
。高效解决这道题就要用到快慢指针技巧:
我们让慢指针
slow
走在后面,快指针fast
走在前面探路,找到一个不重复的元素就赋值给slow
并让slow
前进一步。这样,就保证了
nums[0..slow]
都是无重复的元素,当fast
指针遍历完整个数组nums
后,nums[0..slow]
就是整个数组去重之后的结果。
int removeDuplicates(vector<int>& nums) {
if (nums.size() == 0) {
return 0;
}
int slow = 0, fast = 0;
while (fast < nums.size()) {
if (nums[fast] != nums[slow]) {
slow++;
// 维护 nums[0..slow] 无重复
nums[slow] = nums[fast];
}
fast++;
}
// 数组长度为索引 + 1
return slow + 1;
}
变式一:删除链表中重复出现的元素
给定一个已排序的链表的头 head
, 删除所有重复的元素,使每个元素只出现一次 。返回 已排序的链表 。
其实和数组去重是一模一样的,唯一的区别是把数组赋值操作变成操作指针而已
ListNode* deleteDuplicates(ListNode* head) {
if (head == nullptr) return nullptr;
ListNode* slow = head; // 当前元素
ListNode* fast = head; // 下一个元素
while (fast != nullptr) {
if (fast->val != slow->val) {
// 当前元素的下一个元素设置为下一个不相等的元素
slow->next = fast;
// 当前元素向后移动
slow = slow->next;
}
// 下一个元素向后移动
fast = fast->next;
}
// 断开与后面重复元素的连接
slow->next = nullptr;
return head;
}
这里可能有读者会问,链表中那些重复的元素并没有被删掉,就让这些节点在链表上挂着,合适吗?
这就要探讨不同语言的特性了,像 Java/Python 这类带有垃圾回收的语言,可以帮我们自动找到并回收这些「悬空」的链表节点的内存,而像 C++ 这类语言没有自动垃圾回收的机制,确实需要我们编写代码时手动释放掉这些节点的内存。
不过话说回来,就算法思维的培养来说,我们只需要知道这种快慢指针技巧即可。
除了让你在有序数组/链表中去重,题目还可能让你对数组中的某些元素进行「原地删除」。
变式二:移除元素
给你一个数组 nums 和一个值 val,你需要 原地 移除所有数值等于 val 的元素,并返回移除后数组的新长度。
不要使用额外的数组空间,你必须仅使用 O(1) 额外空间并 原地 修改输入数组。
元素的顺序可以改变。你不需要考虑数组中超出新长度后面的元素。
题目要求我们把 nums
中所有值为 val
的元素原地删除,依然需要使用快慢指针技巧:
如果 fast
遇到值为 val
的元素,则直接跳过,否则就赋值给 slow
指针,并让 slow
前进一步。
这和前面说到的数组去重问题解法思路是完全一样的。
int removeElement(vector<int>& nums, int val) {
int fast = 0, slow = 0;
while (fast < nums.size()) {
if (nums[fast] != val) {
nums[slow] = nums[fast];
slow++;
}
fast++;
}
return slow;
}
注意这里和有序数组去重的解法有一个细节差异
我们这里是先给 nums[slow]
赋值然后再给 slow++
这样可以保证 nums[0..slow-1]
是不包含值为 val
的元素的,最后的结果数组长度就是 slow
变式三:移动0
给定一个数组 nums
,编写一个函数将所有 0
移动到数组的末尾,同时保持非零元素的相对顺序。
请注意 ,必须在不复制数组的情况下原地对数组进行操作。
比如说给你输入 nums = [0,1,4,0,2]
,你的算法没有返回值,但是会把 nums
数组原地修改成 [1,4,2,0,0]
。
结合之前说到的几个题目,你是否有已经有了答案呢?
题目让我们将所有 0 移到最后,其实就相当于移除 nums
中的所有 0,
然后再把后面的元素都赋值为 0 即可。
void moveZeroes(vector<int>& nums) {
// 去除 nums 中的所有 0,返回不含 0 的数组长度
int p = removeElement(nums, 0);
// 将 nums[p..] 的元素赋值为 0
for (; p < nums.size(); p++) {
nums[p] = 0;
}
}
// 见上文代码实现
int removeElement(vector<int>& nums, int val);
滑动窗口算法核心框架详解
三.左右指针
1.二分查找
int binarySearch(vector<int>& nums, int target) {
// 一左一右两个指针相向而行
int left = 0, right = nums.size() - 1;
while(left <= right) {
int mid = (right + left) / 2;
if(nums[mid] == target)
return mid;
else if (nums[mid] < target)
left = mid + 1;
else if (nums[mid] > target)
right = mid - 1;
}
return -1;
}
2.两数之和
给一个升序排列的整数数组,找到两个数使得他们的和等于一个给定的数 target
。
你需要实现的函数需要返回这两个数的下标的大小, 并且第一个下标小于第二个下标。
注意这里下标的范围是 0
到 n-1
只要数组有序,就应该想到双指针技巧。这道题的解法有点类似二分查找,通过调节
left
和right
就可以调整sum
的大小
#include <iostream>
using namespace std;
int main()
{
int n;
cin>>n;
int arr[1000] = {0};
for(int i=0; i<n; i++)
cin>>arr[i];
int target;
cin>>target;
int left,right;
left = 0;
right = n-1;
while(left<right)
{
if(arr[left]+arr[right]==target)
{
printf("arr[%d]:%d + arr[%d]:%d\n",left,arr[left],right,arr[right]);
left++;
right--;
}
else if(arr[left]+arr[right]<target)
left++;
else
right--;
}
return 0;
}
vector<int> twoSum(vector<int>& nums, int target) {
// 一左一右两个指针相向而行
int left = 0, right = nums.size() - 1;
while (left < right) {
int sum = nums[left] + nums[right];
if (sum == target) {
// 题目要求的索引是从 1 开始的
return {left + 1, right + 1};
} else if (sum < target) {
left++; // 让 sum 大一点
} else if (sum > target) {
right--; // 让 sum 小一点
}
}
return {-1, -1};
}
变式一:TwoSum
给一个整数数组,找到两个数使得他们的和等于一个给定的数 target
。
你需要实现的函数需要返回这两个数的下标的大小, 并且第一个下标小于第二个下标。
保证只有且仅有一对元素可以凑出
注意这里下标的范围是 0
到 n-1
注意这里要返回两个数的下标,那么就要在排序后返回两个数的值,再返回原数组中查找
要注意不能用引用,因为不能把原数组改变了,不然就找不到索引了。
vector<int> twoSum(vector<int> nums, int target) {
// 先对数组排序
sort(nums.begin(), nums.end());
// 左右指针
int lo = 0, hi = nums.size() - 1;
while (lo < hi) {
int sum = nums[lo] + nums[hi];
// 根据 sum 和 target 的比较,移动左右指针
if (sum < target) {
lo++;
} else if (sum > target) {
hi--;
} else if (sum == target) {
return {nums[lo], nums[hi]};
}
}
return {};
}
Tips:
一.会改变原数组:
1 添加元素类:(返回新的长度)
push() 把元素添加到数组尾部
unshift() 在数组头部添加元素
2 删除元素类:(返回的是被删除的元素)
pop() 移除数组最后一个元素
shift() 删除数组第一个元素
3 颠倒顺序:
reverse() 在原数组中颠倒元素的顺序
4 插入、删除、替换数组元素:(返回被删除的数组)
splice(index, howmany, item1…intemx)
index代表要操作数组位置的索引值,必填
howmany 代表要删除元素的个数,必须是数字,可以是0,如果没填就是删除从index到数组的结尾
item1…intemx 代表要添加到数组中的新值
5 排序
sort() 对数组元素进行排序
——————————————————————————————————————————————————————————————————————
二.不会改变原数组:
concat() 连接两个或更多数组,返回结果
every() 检测数组中每个元素是否都符合要求
some() 检测数组中是否有元素符合要求
filter() 挑选数组中符合条件的并返回符合要求的数组
join() 把数组的所有元素放到一个字符串
toString() 把数组转成字符串
slice() 截取一段数组,返回新数组
indexOf 搜索数组中的元素,并返回他所在的位置
变式三:所有和为target
的元素对
nums
中可能有多对元素之和都等于 target
请你的算法返回所有和为 target
的元素对,其中不能出现重复。
比如说输入为 nums = [1,3,1,2,2,3], target = 4
,那么算法返回的结果就是:[[1,3],[2,2]]
(注意,我要求返回元素,而不是索引)。
对于修改后的问题,关键难点是现在可能有多个和为 target
的数对儿,还不能重复,比如上述例子中 [1,3]
和 [3,1]
就算重复,只能算一次。
首先,基本思路肯定还是排序加双指针:
3.反转字符串
编写一个函数,其作用是将输入的字符串反转过来。输入字符串以字符数组 s 的形式给出。
不要给另外的数组分配额外的空间,你必须原地修改输入数组、使用 O(1) 的额外空间解决这一问题。
void reverseString(vector<char>& s) {
// 一左一右两个指针相向而行
int left = 0, right = s.size() - 1;
while (left < right) {
// 交换 s[left] 和 s[right]
char temp = s[left];
s[left] = s[right];
s[right] = temp;
left++;
right--;
}
}
4.回文串判断
首先明确一下,回文串就是正着读和反着读都一样的字符串。
比如说字符串 aba
和 abba
都是回文串,因为它们对称,反过来还是和本身一样;
反之,字符串 abac
就不是回文串。
现在你应该能感觉到回文串问题和左右指针肯定有密切的联系,
比如让你判断一个字符串是不是回文串,你可以写出下面这段代码:
bool isPalindrome(string s) {
// 一左一右两个指针相向而行
int left = 0, right = s.length() - 1;
while (left < right) {
if (s[left] != s[right]) { // 如果不相同,就不是回文串
return false;
}
left++;
right--;
}
return true;
}
那接下来我提升一点难度,给你一个字符串,让你用双指针技巧从中找出最长的回文串,你会做吗?
变式一:最长回文子串
找回文串的难点在于,回文串的的长度可能是奇数也可能是偶数
解决该问题的核心是从中心向两端扩散的双指针技巧。
如果回文串的长度为奇数,则它有一个中心字符;
如果回文串的长度为偶数,则可以认为它有两个中心字符。
所以我们可以先实现这样一个函数:
// 在 s 中寻找以 s[l] 和 s[r] 为中心的最长回文串
string palindrome(string s, int l, int r) {
// 防止索引越界
while (l >= 0 && r < s.length()
&& s[l] == s[r]) {
// 双指针,向两边展开
l--; r++;
}
// 返回以 s[l] 和 s[r] 为中心的最长回文串
return s.substr(l + 1, r - l - 1);
}
这样,如果输入相同的 l
和 r
,就相当于寻找长度为奇数的回文串,如果输入相邻的 l
和 r
,则相当于寻找长度为偶数的回文串。
那么回到最长回文串的问题,解法的大致思路就是:
for 0 <= i < len(s):
找到以 s[i] 为中心的回文串
找到以 s[i] 和 s[i+1] 为中心的回文串
更新答案
string longestPalindrome(string s) {
string res = "";
for (int i = 0; i < s.length(); i++) {
// 以 s[i] 为中心的最长回文子串
string s1 = palindrome(s, i, i);
// 以 s[i] 和 s[i+1] 为中心的最长回文子串
string s2 = palindrome(s, i, i + 1);
// res = longest(res, s1, s2)
res = res.length() > s1.length() ? res : s1;
res = res.length() > s2.length() ? res : s2;
}
return res;
}
你应该能发现最长回文子串使用的左右指针和之前题目的左右指针有一些不同:
之前的左右指针都是从两端向中间相向而行,而回文子串问题则是让左右指针从中心向两端扩展。
不过这种情况也就回文串这类问题会遇到,所以我也把它归为左右指针了。
变式二:重排最长回文串(一种即可)
变式三:所有的重排最长回文串(枚举完全)
变式四:所有的回文子串
变式五:判断回文数
变式六:判断回文链表
变式七:分割所得回文串(一种即可)
变式八:分割所得回文串的所有方案
变式九:分割所得回文串的最小分割次数
变式十:允许替换字符以成为回文串的最少替换次数
变式十一:允许插入字符以成为回文串的最少插入次数
变式十二:允许删除字符以成为回文串的最少删除次数
变式十三:允许替换字符以满足可分割成k个回文子串,其所需要修改替换次数
变式十四:在字符串前面添加字符将其转换为回文串的最短回文串
变式十五:允许添加、删除、替换以成为回文串的最少操作次数
变式十六:回文对
四.滑动窗口技巧:
这个算法技巧的思路非常简单,就是维护一个窗口,不断滑动,然后更新答案。
LeetCode 上有起码 10 道运用滑动窗口算法的题目,难度都是中等和困难。该算法的大致逻辑如下:
int left = 0, right = 0;
while (right < s.size()) {
// 增大窗口
window.add(s[right]);
right++;
while (window needs shrink) {
// 缩小窗口
window.remove(s[left]);
left++;
}
}
这个算法技巧的时间复杂度是 O(N),比字符串暴力算法要高效得多。
其实困扰大家的,不是算法的思路,而是各种细节问题。比如说如何向窗口中添加新元素,如何缩小窗口,在窗口滑动的哪个阶段更新结果。即便你明白了这些细节,也容易出 bug,找 bug 还不知道怎么找,真的挺让人心烦的。
所以今天我就写一套滑动窗口算法的代码框架,我连再哪里做输出 debug 都给你写好了
以后遇到相关的问题,你就默写出来如下框架然后改三个地方就行,还不会出 bug:
/* 滑动窗口算法框架 */
void slidingWindow(string s) {
unordered_map<char, int> window;
int left = 0, right = 0;
while (right < s.size()) {
// c 是将移入窗口的字符
char c = s[right];
// 增大窗口
right++;
// 进行窗口内数据的一系列更新
...
/*** debug 输出的位置 ***/
// 注意在最终的解法代码中不要 print
// 因为 IO 操作很耗时,可能导致超时
printf("window: [%d, %d)\n", left, right);
/********************/
// 判断左侧窗口是否要收缩
while (window needs shrink) {
// d 是将移出窗口的字符
char d = s[left];
// 缩小窗口
left++;
// 进行窗口内数据的一系列更新
...
}
}
}
其中两处 ...
表示的更新窗口数据的地方,到时候你直接往里面填就行了。
而且,这两个 ...
处的操作分别是扩大和缩小窗口的更新操作,等会你会发现它们操作是完全对称的。
另外,虽然滑动窗口代码框架中有一个嵌套的 while 循环,但算法的时间复杂度依然是 O(N)
,其中 N
是输入字符串/数组的长度。
为什么呢?简单说,字符串/数组中的每个元素都只会进入窗口一次,然后被移出窗口一次,不会说有某些元素多次进入和离开窗口,所以算法的时间复杂度就和字符串/数组的长度成正比。
滑动窗口很多时候都是在处理字符串相关的问题,不会用到什么特定的编程语言技巧,但是还是简单介绍一下一些用到的数据结构,以免有的读者因为语言的细节问题阻碍对算法思想的理解:
0.pair + map + 哈希的介绍
1.pair
pair是将2个数据组合成一组数据,当需要这样的需求时就可以使用pair
如stl中的map就是将key和value放在一起来保存。
另一个应用是,当一个函数需要返回2个数据的时候,可以选择pair。
pair的实现是一个结构体,主要的两个成员变量是first second 因为是使用struct不是class,所以可以直接使用pair的成员变量。
其标准库类型--pair类型定义在#include <utility>头文件中,定义如下:
类模板:template<class T1,class T2> struct pair
参数:T1是第一个值的数据类型,T2是第二个值的数据类型。
功能:pair将一对值(T1和T2)组合成一个值,
这一对值可以具有不同的数据类型(T1和T2),
两个值可以分别用pair的两个公有函数first和second访问。
定义(构造函数):
pair<T1, T2> p1; //创建一个空的pair对象(使用默认构造),它的两个元素分别是T1和T2类型,采用值初始化。
pair<T1, T2> p1(v1, v2); //创建一个pair对象,它的两个元素分别是T1和T2类型,其中first成员初始化为v1,second成员初始化为v2。
make_pair(v1, v2); // 以v1和v2的值创建一个新的pair对象,其元素类型分别是v1和v2的类型。
p1 < p2; // 两个pair对象间的小于运算,其定义遵循字典次序:如 p1.first < p2.first 或者 !(p2.first < p1.first) && (p1.second < p2.second) 则返回true。
p1 == p2; // 如果两个对象的first和second依次相等,则这两个对象相等;该运算使用元素的==操作符。
p1.first; // 返回对象p1中名为first的公有数据成员
p1.second; // 返回对象p1中名为second的公有数据成员
2.pair的创建和初始化
pair包含两个数值,与容器一样,pair也是一种模板类型。
但是又与之前介绍的容器不同;
在创建pair对象时,必须提供两个类型名,两个对应的类型名的类型不必相同
pair<string, string> anon; // 创建一个空对象anon,两个元素类型都是string
pair<string, int> word_count; // 创建一个空对象 word_count, 两个元素类型分别是string和int类型
pair<string, vector<int> > line; // 创建一个空对象line,两个元素类型分别是string和vector类型
当然也可以在定义时进行成员初始化:
pair<string, string> author("James","Joy"); // 创建一个author对象,两个元素类型分别为string类型,并默认初始值为James和Joy。
pair<string, int> name_age("Tom", 18);
pair<string, int> name_age2(name_age); // 拷贝构造初始化
pair类型的使用相当的繁琐,如果定义多个相同的pair类型对象,可以使用typedef简化声明:
typedef pair<string,string> Author;
Author proust("March","Proust");
Author Joy("James","Joy");
变量间赋值:
pair<int, double> p1(1, 1.2);
pair<int, double> p2 = p1; // copy construction to initialize object
pair<int, double> p3;
p3 = p1; // operator =
3.pair对象的操作
访问两个元素操作可以通过first和second访问:
pair<int ,double> p1;
p1.first = 1;
p1.second = 2.5;
cout<<p1.first<<' '<<p1.second<<endl;
//输出结果:1 2.5
string firstBook;
if(author.first=="James" && author.second=="Joy")
firstBook="Stephen Hero";
4.生成新的pair对象
可以利用make_pair创建新的pair对象:
pair<int, double> p1;
p1 = make_pair(1, 1.2);
cout << p1.first << p1.second << endl;
//output: 1 1.2
int a = 8;
string m = "James";
pair<int, string> newone;
newone = make_pair(a, m);
cout << newone.first << newone.second << endl;
//output: 8 James
5.通过tie获取pair元素值
在某些清况函数会以pair对象作为返回值时,可以直接通过std::tie进行接收。比如:
std::pair<std::string, int> getPreson() {
return std::make_pair("Sven", 25);
}
int main(int argc, char **argv) {
std::string name;
int ages;
std::tie(name, ages) = getPreson();
std::cout << "name: " << name << ", ages: " << ages << std::endl;
return 0;
}
1.map的简介
STL的一个关联容器,它提供一对一的hash
- 第一个可以称为关键字(key),每个关键字只能在map中出现一次;
- 第二个可以称为该关键字的值(value);
map以模板(泛型)方式实现,可以存储任意类型的数据,包括使用者自定义的数据类型。
Map主要用于资料一对一映射(one-to-one)的情況,map內部的实现自建一颗红黑树,这颗树具有对数据自动排序的功能。
在map内部所有的数据都是有序的,后边我们会见识到有序的好处。
比如一个班级中,每个学生的学号跟他的姓名就存在著一对一映射的关系。
2.map的功能
1.自动建立key - value的对应。key 和 value可以是任意你需要的类型,包括自定义类型。
2.使用 #include <map> //注意,STL头文件没有扩展名.h
map对象是模板类,需要关键字和存储对象两个模板参数:
std:map<int, string> personnel;
这样就定义了一个用int作为索引,并拥有相关联的指向string的指针.
为了使用方便,可以对模板类进行一下类型定义,
typedef map<int,CString> UDT_MAP_INT_CSTRING;
UDT_MAP_INT_CSTRING enumMap;
3.map的构造函数
map共提供了6个构造函数,这块涉及到内存分配器这些东西,略过不表,在下面我们将接触到一些map的构造方法,这里要说下的就是,我们通常用如下方法构造一个map:
map<int, string> mapStudent;
4.插入元素
// 定义一个map对象
map<int, string> mapStudent;
// 第一种 用insert函數插入pair
mapStudent.insert(pair<int, string>(000, "student_zero"));
// 第二种 用insert函数插入value_type数据
mapStudent.insert(map<int, string>::value_type(001, "student_one"));
// 第三种 用"array"方式插入
mapStudent[123] = "student_first";
mapStudent[456] = "student_second";
以上三种用法,虽然都可以实现数据的插入,但是它们是有区别的
当然了第一种和第二种在效果上是完成一样的,用insert函数插入数据,在数据的插入上涉及到集合的唯一性
即当map中有这个关键字时,insert操作是不能在插入数据的
但是用数组方式就不同了,它可以覆盖以前该关键字对 应的值,用程序说明如下:
mapStudent.insert(map<int, string>::value_type (001, "student_one"));
mapStudent.insert(map<int, string>::value_type (001, "student_two"));
上面这两条语句执行后,map中001这个关键字对应的值是“student_one”,第二条语句并没有生效
可以用pair来获得是否插入成功,程序如下
// 构造定义,返回一个pair对象
pair<iterator,bool> insert (const value_type& val);
pair<map<int, string>::iterator, bool> Insert_Pair;
Insert_Pair = mapStudent.insert(map<int, string>::value_type (001, "student_one"));
if(!Insert_Pair.second)
cout << ""Error insert new element" << endl;
我们通过pair的第二个变量来知道是否插入成功,它的第一个变量返回的是一个map的迭代器,如果插入成功的话Insert_Pair.second应该是true的,否则为false。
5. 查找元素
当所查找的关键key出现时,它返回数据所在对象的位置,如果沒有,返回iter与end函数的值相同。
// find 返回迭代器指向当前查找元素的位置否则返回map::end()位置
iter = mapStudent.find("123");
if(iter != mapStudent.end())
cout<<"Find, the value is"<<iter->second<<endl;
else
cout<<"Do not Find"<<endl;
6. 刪除与清空元素
//迭代器刪除
iter = mapStudent.find("123");
mapStudent.erase(iter);
//用关键字刪除
int n = mapStudent.erase("123"); //如果刪除了會返回1,否則返回0
//用迭代器范围刪除 : 把整个map清空
mapStudent.erase(mapStudent.begin(), mapStudent.end());
//等同于mapStudent.clear()
7.map的大小
在往map里面插入了数据,我们怎么知道当前已经插入了多少数据呢,可以用size函数,用法如下:
int nSize = mapStudent.size();
8.遍历map
int main() {
map<int, int> _map;
_map[0] = 1;
_map[1] = 2;
_map[10] = 10;
map<int, int>::iterator iter;
iter = _map.begin();
while(iter != _map.end()) {
cout << iter->first << " : " << iter->second << endl;
iter++;
}
// 也可以使用for循环遍历
for(iter = _map.begin(); iter != _map.end(); iter++) {
cout << iter->first << " : " << iter->second << endl;
}
return 0;
}
9.map的基本操作函数:
C++ maps是一种关联式容器,包含“关键字/值”对
begin() 返回指向map头部的迭代器
clear() 删除所有元素
count() 返回指定元素出现的次数 (帮助理解: 因为key值不会重复,所以只能是1 or 0)
empty() 如果map为空则返回true
end() 返回指向map末尾的迭代器
equal_range() 返回特殊条目的迭代器对
erase() 删除一个元素
find() 查找一个元素
get_allocator() 返回map的配置器
insert() 插入元素
key_comp() 返回比较元素key的函数
lower_bound() 返回键值>=给定元素的第一个位置
max_size() 返回可以容纳的最大元素个数
rbegin() 返回一个指向map尾部的逆向迭代器
rend() 返回一个指向map头部的逆向迭代器
size() 返回map中元素的个数
swap() 交换两个map
upper_bound() 返回键值>给定元素的第一个位置
value_comp() 返回比较元素value的函数
3.unordered_map(用于实现计数count)
unordered_map与map的对比
存储时是根据key的hash值判断元素是否相同,即unordered_map内部元素是无序的
而map中的元素是按照二叉搜索树存储(用红黑树实现),进行中序遍历会得到有序遍历
所以使用时map的key需要定义operator<。
而unordered_map需要定义hash_value函数并且重载operator==。
但是很多系统内置的数据类型都自带这些。
成员函数
1. 迭代器
begin 返回指向容器起始位置的迭代器(iterator)
end 返回指向容器末尾位置的迭代器
cbegin 返回指向容器起始位置的常迭代器(const_iterator)
cend 返回指向容器末尾位置的常迭代器
元素的键值分别是迭代器的first和second属性。使用(*it).first或者it->first获取。
2. 容量
size 返回有效元素个数
max_size 返回 unordered_map 支持的最大元素个数
empty 判断是否为空
3. 元素插入与删除
insert 插入元素
erase 删除元素 ,可以通过迭代器或者key进行删除
clear 清空内容
swap 交换内容
unordered_map<int,int> mymap;
//插入
mymap.insert({1,0});//数组插入
mymap[1] = 0;//键值插入
mymap.insert(mymap2.begin(),mymap2.end());//插入另一个哈希表中的元素
mymap.insert(pair<int,int>(0,1));
//删除
mymap.erase(mymap.begin());
mymap.erase(1);
mymap.clear();
4. 查找
find 通过给定主键查找元素,没找到:返回unordered_map::end
count 返回匹配给定主键的元素的个数
equal_range 返回值匹配给定搜索值的元素组成的范围
if (mymap.find(0) != mymap.end())
cout << "not found" << endl;
else
cout << "found" << endl;
值得注意的是哈希表中的count只会有0或1两种情况!!!!
1.模板套路之最小覆盖字串
给你一个字符串 s
、一个字符串 t
。返回 s
中涵盖 t
所有字符的最小子串。
如果 s
中不存在涵盖 t
所有字符的子串,则返回空字符串 ""
。
就是说要在 S
(source) 中找到包含 T
(target) 中全部字母的一个子串,且这个子串一定是所有可能子串中最短的。如果我们使用暴力解法,代码大概是这样的:
for (int i = 0; i < s.size(); i++)
for (int j = i + 1; j < s.size(); j++)
if s[i:j] 包含 t 的所有字母:
更新答案
滑动窗口算法的思路是这样:
1、我们在字符串 S
中使用双指针中的左右指针技巧
初始化 left = right = 0
,把索引左闭右开区间 [left, right)
称为一个「窗口」。
理论上你可以设计两端都开或者两端都闭的区间,但设计为左闭右开区间是最方便处理的。
因为这样初始化 left = right = 0
时区间 [0, 0)
中没有元素,但只要让 right
向右移动(扩大)一位,区间 [0, 1)
就包含一个元素 0
了。
如果你设置为两端都开的区间,那么让 right
向右移动一位后开区间 (0, 1)
仍然没有元素;
如果你设置为两端都闭的区间,那么初始区间 [0, 0]
就包含了一个元素。
这两种情况都会给边界处理带来不必要的麻烦!!!!!!!!!!!!!!
2、我们先不断地增加 right
指针扩大窗口 [left, right)
,直到窗口中的字符串符合要求(包含了 T
中的所有字符)。
3、此时,我们停止增加 right
,转而不断增加 left
指针缩小窗口 [left, right)
,直到窗口中的字符串不再符合要求(不包含 T
中的所有字符了)。同时,每次增加 left
,我们都要更新一轮结果。
4、重复第 2 和第 3 步,直到 right
到达字符串 S
的尽头。
第 2 步相当于在寻找一个「可行解」
然后第 3 步在优化这个「可行解」,最终找到最优解,也就是最短的覆盖子串。
左右指针轮流前进,窗口大小增增减减,窗口不断向右滑动,这就是「滑动窗口」名字的来历。
下面画图理解一下,needs
和 window
相当于计数器,分别记录 T
中字符出现次数和「窗口」中的相应字符的出现次数。
初始状态:
增加 right,直到窗口 [left, right)
包含了 T
中所有字符:
现在开始增加 left
,缩小窗口 [left, right)
:
直到窗口中的字符串不再符合要求,left
不再继续移动:
之后重复上述过程,先移动 right
,再移动 left
…… 直到 right
指针到达字符串 S
的末端,算法结束。
如果你能够理解上述过程,恭喜,你已经完全掌握了滑动窗口算法思想。现在我们来看看这个滑动窗口代码框架怎么用:
首先,初始化 window
和 need
两个哈希表,记录窗口中的字符和需要凑齐的字符:
unordered_map<char, int> need, window;
for (char c : t) need[c]++;
其实这里用一个二维整型数组也可以arr【26】【2】:有26行,代表26个字母;有2列,分别表示需要的字符数,和窗口中的字符数,不过这样每次都要遍历一遍二维数组,有点麻烦
或者用两个数组:一个一维字符数组用于存储出现的字符,一个二维整型数组来做一个表
然后,使用 left
和 right
变量初始化窗口的两端,不要忘了,区间 [left, right)
是左闭右开的,所以初始情况下窗口没有包含任何元素:
int left = 0, right = 0;
int valid = 0;
while (right < s.size()) {
// 开始滑动
}
其中 valid
变量表示窗口中满足 need
条件的字符个数
如果 valid
和 need.size
的大小相同,则说明窗口已满足条件,已经完全覆盖了串 T
。
现在开始套模板,只需要思考以下几个问题:
1、什么时候应该移动 right
扩大窗口?窗口加入字符时,应该更新哪些数据?
2、什么时候窗口应该暂停扩大,开始移动 left
缩小窗口?从窗口移出字符时,应该更新哪些数据?
3、我们要的结果应该在扩大窗口时还是缩小窗口时进行更新?
如果一个字符进入窗口,应该增加 window
计数器;
如果一个字符将移出窗口的时候,应该减少 window
计数器;
当 valid
满足 need
时应该收缩窗口;
应该在收缩窗口的时候更新最终结果。
下面是完整代码:
string minWindow(string s, string t) {
unordered_map<char, int> need, window;
for (char c : t) need[c]++;
int left = 0, right = 0;
int valid = 0;
// 记录最小覆盖子串的起始索引及长度
int start = 0, len = INT_MAX;
while (right < s.size()) {
// c 是将移入窗口的字符
char c = s[right];
// 扩大窗口
right++;
// 进行窗口内数据的一系列更新
if (need.count(c)) {
window[c]++;
if (window[c] == need[c])
valid++;
}
// 判断左侧窗口是否要收缩
while (valid == need.size()) {
// 在这里更新最小覆盖子串
if (right - left < len) {
start = left; //更新起始位置
len = right - left; //更新当前的最小长度
}
// d 是将移出窗口的字符
char d = s[left];
// 缩小窗口
left++;
// 进行窗口内数据的一系列更新
if (need.count(d)) {
if (window[d] == need[d]) //如果恰好满足后再减少,那么就不再满足need的条件了
valid--;
window[d]--; //不管多不多余都减一
}
}
}
// 返回最小覆盖子串
return len == INT_MAX ?
"" : s.substr(start, len);
}
需要注意的是,当我们发现某个字符在 window
的数量满足了 need
的需要,就要更新 valid
,表示有一个字符已经满足要求。而且,你能发现,两次对窗口内数据的更新操作是完全对称的。
当 valid == need.size()
时,说明 T
中所有字符已经被覆盖,已经得到一个可行的覆盖子串,现在应该开始收缩窗口了,以便得到「最小覆盖子串」。
移动 left
收缩窗口时,窗口内的字符都是可行解,所以应该在收缩窗口的阶段进行最小覆盖子串的更新,以便从可行解中找到长度最短的最终结果。
至此,应该可以完全理解这套框架了,滑动窗口算法又不难,就是细节问题让人烦得很。以后遇到滑动窗口算法,你就按照这框架写代码,保准没有 bug,还省事儿。
#include <iostream>
#include <unordered_map>
using namespace std;
string SlidingWindow(string s,string t)
{
unordered_map<char,int> need, window;
for(char c:t) need[c]++;
int left,right;
left = right = 0;
int valid = 0;
int start = 0;
int len = s.size();
while(right < (int)s.size())
{
char c = s[right];
right++;
if(need.count(c))
{
window[c]++;
if(window[c]==need[c])
valid++;
}
while(valid == (int)need.size())
{
if(right - left <len)
{
start = left;
len = right - left;
}
char d = s[left];
left++;
if(need.count(d))
{
if(window[d] == need[d])
valid--;
window[d]--;
}
}
}
return len == (int)s.size() ? "" : s.substr(start,len);
}
3.字符串排列
给你两个字符串 s1 和 s2 ,写一个函数来判断 s2 是否包含 s1 的排列。
如果是,返回 true ;否则,返回 false 。
换句话说,s1 的排列之一是 s2 的 子串 。
注意哦,输入的 s1
是可以包含重复字符的,所以这个题难度不小。
这种题目,是明显的滑动窗口算法:
相当给你一个 S
和一个 T
,请问你 S
中是否存在一个子串,包含 T
中所有字符且不包含其他字符?
对于这道题的解法代码,基本上和最小覆盖子串一模一样,只需要改变几个地方:
1、本题移动 left
缩小窗口的时机是窗口长度大于 t.size()
时,因为排列嘛,显然长度应该是一样的。
2、当发现 valid == need.size()
时,就说明窗口中就是一个合法的排列,所以立即返回 true
。
至于如何处理窗口的扩大和缩小,和最小覆盖子串完全相同。
由于这道题中
[left, right)
其实维护的是一个定长的窗口,窗口大小为t.size()
。因为定长窗口每次向前滑动时只会移出一个字符,所以可以把内层的 while 改成 if,效果是一样的。
#include <iostream>
#include <unordered_map>
using namespace std;
string SlidingWindow(string s,string t)
{
unordered_map<char,int> need, window;
for(char c:t) need[c]++;
int left,right;
left = right = 0;
int valid = 0;
while(right < (int)s.size())
{
char c = s[right];
right++;
if(need.count(c))
{
window[c]++;
if(window[c]==need[c])
valid++;
}
while(right-left >= (int)t.size())
{
if(valid == (int)need.size())
return s.substr(left,t.size());
char d = s[left];
left++;
if(need.count(d))
{
if(window[d] == need[d])
valid--;
window[d]--;
}
}
}
return "";
}
int main()
{
string s,t;
cin>>s;
cin>>t;
cout<<SlidingWindow(s,t);
return 0;
}
一定要明确:valid 的值就是满足了need条件的字符的个数
4. 所有字母异位词
给定两个字符串 s 和 p,找到 s 中所有 p 的 异位词 的子串,返回这些子串的起始索引。
不考虑答案输出的顺序。
异位词:指由相同字母重排列形成的字符串(包括相同的字符串)。
这个所谓的字母异位词,不就是排列吗,搞个高端的说法就能糊弄人了吗?
相当于,输入一个串 S
,一个串 T
,找到 S
中所有 T
的排列,返回它们的起始索引。
跟寻找字符串的排列一样,只是找到一个合法异位词(排列)之后将起始索引加入 res
即可。
abcdcabdbacdbcad
abc
ret[0]:0 ret[1]:4 ret[2]:8 ret[3]:12
#include <iostream>
#include <unordered_map>
#include <vector>
using namespace std;
vector<int> SlidingWindow(string s,string t)
{
unordered_map<char,int> need, window;
vector<int> ret;
for(char c:t) need[c]++;
int left,right;
left = right = 0;
int valid = 0;
while(right < (int)s.size())
{
char c = s[right];
right++;
if(need.count(c))
{
window[c]++;
if(window[c]==need[c])
valid++;
}
while(right-left != (int)t.size())
{
if(valid == (int)need.size())
ret.push_back(left);
char d = s[left];
left++;
if(need.count(d))
{
if(window[d] == need[d])
valid--;
window[d]--;
}
}
}
return ret;
}
int main()
{
string s,t;
cin>>s;
cin>>t;
int i =0 ;
vector<int> ret = SlidingWindow(s,t);
for(vector<int>::iterator iter = ret.begin(); iter != ret.end(); i++ , iter++)
cout<<"ret["<<i<<"]:"<<*iter<<" ";
return 0;
}
5.最长无重复字符的子串
给定一个字符串 s
,请你找出其中不含有重复字符的 最长子串 的长度。
这就是变简单了,连 need
和 valid
都不需要,而且更新窗口内数据也只需要简单的更新计数器 window
即可。
当 window[c]
值大于 1 时,说明窗口中存在重复字符,不符合条件,就该移动 left
缩小窗口了嘛。
唯一需要注意的是,在哪里更新结果 res
呢?
我们要的是最长无重复子串,哪一个阶段可以保证窗口中的字符串是没有重复的呢?
这里和之前不一样,要在收缩窗口完成后更新 res
,因为窗口收缩的 while 条件是存在重复元素,换句话说收缩完成后一定保证窗口中没有重复嘛。
#include <iostream>
#include <unordered_map>
#include <vector>
using namespace std;
int SlidingWindow(string s)
{
unordered_map<char,int> window;
int left,right;
left = right = 0;
int ret;
while(right < (int)s.size())
{
char c = s[right];
right++;
window[c]++;
while(window[c]>1)
{
char d = s[left];
left++;
window[d]--;
}
ret = max(ret,right-left);
}
return ret;
}
int main()
{
string s;
cin>>s;
cout<<SlidingWindow(s);
return 0;
}
回顾一下,遇到子数组/子串相关的问题,你只要能回答出来以下几个问题,就能运用滑动窗口算法:
1、什么时候应该扩大窗口?
2、什么时候应该缩小窗口?
3、什么时候应该更新答案?