【算法思维--双指针技巧】LeetCode数组类题目等

24 篇文章 0 订阅
4 篇文章 0 订阅

刷题的过程关键是通过 语言刷算法解题的思维过程。
只要把题目搞清晰,会做coding好了、这一类都会了,目的就达到了。
当然, C++一方面很多书籍在用其表达;另一方面自己之前也学过相关的数据结构,灵活运用在算法方面的能力很强。
其它,大项目可以用别的语言。<工具 暂不是大问题。>

算法思维解释

本文通常将双指针可以分为两类:一类是【快慢指针】,另一类是【左右指针】;

  1. 快慢指针:通常解决链表中的问题,比如典型的判定链表中是否包含环;
  2. 左右指针:主要解决数组(或字符串)中的问题比如:二分查找、滑动窗口、nSum问题。

左右指针主要用于遍历数组,两个指针指向不同的元素,从而协同完成任务。也可以延伸到多 个数组的多个指针。
若两个指针指向同一数组,遍历方向相同且不会相交,则也称为滑动窗口(两个指针包围的 区域即为当前的窗口),经常用于区间搜索。
若两个指针指向同一数组,但是遍历方向相反,则可以用来进行搜索,待搜索的数组往往是 排好序的。

快慢指针

快慢指针一般都初始化指向链表的头结点 head,前进时快指针 fast 在前,慢指针 slow 在后,巧妙解决一些链表中的问题。

1、判定链表中是否含有环

这应该属于链表最基本的操作了,如果读者已经知道这个技巧,可以跳过。

单链表的特点是每个节点只知道下一个节点,所以一个指针的话无法判断链表中是否含有环的。

如果链表中不含环,那么这个指针最终会遇到空指针 null 表示链表到头了,这还好说,可以判断该链表不含环。

boolean hasCycle(ListNode head) {
    while (head != null)
        head = head.next;
    return false;
}

但是如果链表中含有环,那么这个指针就会陷入死循环,因为环形数组中没有 null 指针作为尾部节点。

经典解法就是用两个指针,一个每次前进两步,一个每次前进一步。如果不含有环,跑得快的那个指针最终会遇到 null,说明链表不含环;如果含有环,快指针最终会超慢指针一圈,和慢指针相遇,说明链表含有环。

在这里插入图片描述

注意:要深挖追逐的本质.

【LeetCode】142.LinkedListCycle

题目既需要找出是否存在环路; 还需要返回链中的节点。

  • 首先,快指针和慢指针 如果只快一步的话,每走一次,快指针将快于慢指针 —— 而且如果两者相遇,在相遇点之前假设 slow走了 k步,【一圈的长度也是k步】,那么 因为相遇了——所以 fast是走了 2k步。

  • 而且,最终第一次相遇必然是在 链尾 但是,第一次相遇不一定是在链尾, 也可能在中间——因为 设相遇点距环的起点的距离为 m,那么环的起点距头结点 head 的距离为 k - m,也就是说如果从 head 前进 k - m 步就能到达环起点。
    巧的是,如果从相遇点继续前进 k - m 步,也恰好到达环起点。

  • 所以,我们 要求链中,只需要把 slow==head, 然后找到 fast->next == slow++;的那个slow 即可

101书中:对于链表找环路的问题,有一个通用的解法——快慢指针(Floyd 判圈法)。给定两个指针,
分别命名为 slow 和 fast,起始位置在链表的开头。每次 fast 前进两步,slow 前进一步。如果 fast
可以走到尽头,那么说明没有环路;如果 fast 可以无限走下去,那么说明一定有环路,且一定存
在一个时刻 slow 和 fast 相遇。当 slow 和 fast 第一次相遇时,我们将 fast 重新移动到链表开头,并
让 slow 和 fast 每次都前进一步。当 slow 和 fast 第二次相遇时,相遇的节点即为环路的开始点。

ListNode *detectCycle(ListNode *head) {
		ListNode *slow = head, *fast = head;
		// 判断是否存在环路
		do {
			if (!fast || !fast->next) return nullptr;
				fast = fast->next->next;
				slow = slow->next;
		} while (fast != slow);
		// 如果存在,查找环路节点
		fast = head;
		while (fast != slow){
			slow = slow->next;
			fast = fast->next;
		}
		return fast;
}

2、已知链表中含有环,返回这个环的起始位置

在这里插入图片描述

这个问题其实不困难,有点类似脑筋急转弯,先直接看代码:

在这里插入图片描述

可以看到,当快慢指针相遇时,让其中任一个指针重新指向头节点,然后让它俩以相同速度前进,再次相遇时所在的节点位置就是环开始的位置。这是为什么呢?

第一次相遇时,假设慢指针 slow 走了 k 步,那么快指针 fast 一定走了 2k 步,也就是说比 slow 多走了 k 步(也就是环的长度)。
在这里插入图片描述

设相遇点距环的起点的距离为 m,那么环的起点距头结点 head 的距离为 k - m,也就是说如果从 head 前进 k - m 步就能到达环起点。

巧的是,如果从相遇点继续前进 k - m 步,也恰好到达环起点。

在这里插入图片描述

所以,只要我们把快慢指针中的任一个重新指向 head,然后两个指针同速前进,k - m 步后就会相遇,相遇之处就是环的起点了。

3、寻找链表的中点

类似上面的思路,我们还可以让快指针一次前进两步,慢指针一次前进一步,当快指针到达链表尽头时,慢指针就处于链表的中间位置。

ListNode slow, fast;
slow = fast = head;
while (fast != null && fast.next != null) {
    fast = fast.next.next;
    slow = slow.next;
}
// slow 就在中间位置
return slow;

当链表的长度是奇数时,slow 恰巧停在中点位置;如果长度是偶数,slow 最终的位置是中间偏右:

在这里插入图片描述

寻找链表中点的一个重要作用是对链表进行归并排序。

回想数组的归并排序:求中点索引递归地把数组二分,最后合并两个有序数组。对于链表,合并两个有序链表是很简单的,难点就在于二分。

但是现在你学会了找到链表的中点,就能实现链表的二分了。关于归并排序的具体内容本文就不具体展开了。

4、寻找链表的倒数第 k 个元素

我们的思路还是使用快慢指针,让快指针先走 k 步,然后快慢指针开始同速前进。这样当快指针走到链表末尾 null 时,慢指针所在的位置就是倒数第 k 个链表节点(为了简化,假设 k 不会超过链表长度):

ListNode slow, fast;
slow = fast = head;
while (k-- > 0) 
    fast = fast.next;

while (fast != null) {
    slow = slow.next;
    fast = fast.next;
}
return slow;

5、其它单链表基本操作 - 非快慢指针

定义
链表:由一系列结点(链表中每一个元素称为结点)组成,每个结点包括两个部分:一个是存储数据元素的数据域,另一个是存储下一个结点地址的指针域。
在这里插入图片描述

typedef struct ListNode{
	int val;
	struct ListNode* next;
	ListNode(int x) :
		val(x), next(NULL){
	}
};

基本操作

  1. 创建单链表
    后面的操作均在其基础上进行修改
    步骤:

创建头节点head,并且将当前结点p指向头结点(p=head)
创建下一个结点q,当前结点p的下一结点为q(p->next=q)
结点p后移一位(p = p->next)

#include <iostream>
#include<vector>

using namespace std;

struct ListNode{
	int val;
	struct ListNode* next;
	ListNode(int x) :
		val(x), next(NULL){
	}
};

int main(){
	int num;
	cin >> num;
	ListNode* head = new ListNode(num);
	ListNode* p = head;
	
	//利用尾插法创建一个链表
	while (cin >> num){
		ListNode* q = new ListNode(num);
		p->next = q; 
		p = p->next;
	}

	//遍历这个链表,并输出每个结点的元素
	ListNode* m = head;
	while (m != nullptr){
		cout << m->val << endl;
		m = m->next;
	}

	return 0;

}
  1. 插入节点
    判断原链表是否是空链表,如果是,将head指向新增结点
    如果不是空链表,向链表尾部插入新结点
ListNode* insertNode(ListNode* head, int data){
	ListNode* newNode = new ListNode(data);
	ListNode* p = head;
	if (p == nullptr){
		head = newNode;
	}
	else{
		while (p->next != nullptr){
			p = p->next;
		}
		p->next = newNode;
	}
	return head;
}
  1. 删除节点
    在这里插入图片描述
    单链表删除结点
ListNode* deleteNode(ListNode* head, int data){
	ListNode* p = head;
	//首先判断是不是空链表
	if (p == nullptr){
		return head;
	}
	else{
		//判断是不是删除头节点
		if (p->val == data){
			head = p->next;
			delete p;
			return head;
		}
		else{
			//如果有该结点,遍历到待删除节点的前一节点
			while (p->next != nullptr && p->next->val != data){
				p = p->next;
			}
			//遍历完整个链表都没有待删除节点
			if (p->next == nullptr){
				return head;
			}
			else{
				ListNode* deleteNode = p->next;
				p->next = deleteNode->next;
				delete deleteNode;
				return head;
			}
		}
	}
}
  1. 反转链表
    在这里插入图片描述
    如上图,假设在I开始反转,那么需要知道i前面的h,还要保留i后面的j,防止链表断裂
    假设pNode是当前的节点,pPrev是pNode前面的节点,PNext是PNode后面的节点,那么:
    当pNode不为nullptr,且pNext不为nullptr的时候

1 将pNode指向pPrev(pNode->next = pPrev)
2 将pNode给pPrev(pPrev= pNode)
3 将pNext给pNode(pNode = pNext)
当pNode不为nullptr,且pNext==nullptr的时候,把反转后的头部指向pNode
注意:判断边界条件

#include <iostream>
#include<vector>

using namespace std;

struct ListNode{
	int val;
	struct ListNode* next;
	ListNode(int x) :
		val(x), next(NULL){
	}
};

//反转链表
ListNode* reverse(ListNode* head){
	ListNode* pPrev = nullptr;
	ListNode* p = head;
	ListNode* pReverseHead = nullptr;
	while (p != nullptr){
		ListNode* pNext = p->next;
		if (pNext == nullptr){
			pReverseHead = p;
		}
		p->next = pPrev;
		pPrev = p;
		p = pNext;
	}
	return pReverseHead;
}

int main(){
	int num;
	cin >> num;
	ListNode* head = new ListNode(num);
	ListNode* p = head;
	while (cin >> num){
		ListNode* q = new ListNode(num);
		p->next = q;
		p = p->next;
	}
	p->next = nullptr;


	ListNode* result = reverse(head);
	ListNode* node = result;

	while (node != nullptr){
		cout << node->val << endl;
		node = node->next;
	}

	return 0;

左右指针的常用算法

左右指针主要用于遍历数组,两个指针指向不同的元素,从而协同完成任务。也可以延伸到多 个数组的多个指针。
若两个指针指向同一数组,遍历方向相同且不会相交,则也称为滑动窗口(两个指针包围的 区域即为当前的窗口),经常用于区间搜索。【子字符串的匹配问题——一个大的话题,在下一篇写出来。】
若两个指针指向同一数组,但是遍历方向相反,则可以用来进行搜索,待搜索的数组往往是 排好序的。

  1. KSum问题–双指针去除重复思维
  2. 二分查找
  3. 翻转数组
  4. 滑动窗口【下一节】

1、KSum类型思维

TwoSum总结

对于 TwoSum 问题,⼀个难点就是给的数组⽆序。对于⼀个⽆序的数组, 我们似乎什么技巧也没有,只能暴⼒穷举所有可能。
**⼀般情况下,我们会⾸先把数组①排序②再考虑双指针技巧。**TwoSum 启发我 们,HashMap 或者 HashSet 也可以帮助我们处理⽆序数组相关的简单问题。 另外,设计的核⼼在于权衡,利⽤不同的数据结构,可以得到⼀些针对性的 加强。
最后,如果 TwoSum I 中给的数组是有序的,应该如何编写算法呢?答案很 简单,前⽂「双指针技巧汇总」写过:


int[]	twoSum(int[]	nums,	int	target)	{				
	int	left	=	0,	right	=	nums.length	-	1;				
	while	(left	<	right)	{								
		int	sum	=	nums[left]	+	nums[right];								
		if	(sum	==	target)	{
 			return	new	int[]{left,	right};								
 		}	else	if	(sum	<	target)	{												
 			left++;	//	让	sum	⼤⼀点								
 		}	else	if	(sum	>	target)	{												
 			right--;	//	让	sum	⼩⼀点								
 		}				
 	}				
 	//	不存在这样两个数				
 	return	new	int[]{-1,	-1}; 
 } 

ThreeSum

15题 三数之和解题思路

题目描述:

给你一个包含 n 个整数的数组 nums,判断 nums 中是否存在三个元素 a,b,c ,使得 a + b + c = 0
?请你找出所有满足条件且不重复的三元组。 注意:答案中不可以包含重复的三元组。 示例:

给定数组 nums = [-1, 0, 1, 2, -1, -4],

满足要求的三元组集合为: [ [-1, 0, 1], [-1, -1, 2] ]

思路:

标签:数组遍历

  • 首先对数组进行排序,排序后固定一个数 nums[i],再使用左右指针指向 nums[i]后面的两端,数字分别为 nums[L]和 nums[R],计算三个数的和 sum判断是否满足为 0,满足则添加进结果集
  • 如果 nums[i]大于 0,则三数之和必然无法等于 0,结束循环
  • 如果 nums[i] == nums[i−1],则说明该数字重复,会导致结果重复,所以应该跳过
  • 当 sum== 0 时,nums[L] == nums[L+1]则会导致结果重复,应该跳过,L++
  • 当 sum== 0 时,nums[R] == nums[R−1] 则会导致结果重复,应该跳过,R−−
  • 时间复杂度:O(n^2),n 为数组长度

代码:

# Solution2:
# 关键之处在于边界,以及去重
from typing import List
class Solution:
    def threeSum(self, nums: List[int]) -> List[List[int]]:
        n = len(nums)
        if( not nums or n < 3):
            return []
        res = []
        sort_num = sorted(nums)
        for i,v in enumerate(sort_num):
            if(sort_num[i] > 0): # 遗漏了!如果当前数字大于0,则三数之和一定大于0,所以结束循环
                return res
            # 去重
            if i >= 1 and sort_num[i] == sort_num[i-1]: 
                continue
            L = i+1; R = n-1
            while L<R:
                sum = sort_num[i] + sort_num[L] + sort_num[R]
                if sum == 0:
                    res.append([sort_num[i], sort_num[L], sort_num[R]])
                    # 去重并寻找下一个不是次数的数值
                    L+=1; R-=1; # 可以这样 因为排好序了,已经不可能只移动一边了! YES!【关键这里!】
                    while(L<R and sort_num[L] == sort_num[L-1]): L+=1
                    while(R>L and sort_num[R] == sort_num[R+1]): R-=1
                elif sum>0:
                    R-=1
                elif sum < 0 :
                    L+=1
        return res

if __name__ == "__main__":
    a = Solution()
    # print(a.threeSum([-1,0,1,2,-1,-4]))
    # print(a.threeSum([0,0,0,0]))
    print(a.threeSum([1,0, -1]))
    # print(a.threeSum([-4,-2,-2,-2,0,1,2,2,2,3,3,4,4,6,6]))

引用图解:
在这里插入图片描述

关键之处与C++实现
#include <iostream>
#include <vector>
#include <windows.h>
#include <algorithm>

using namespace std;

// 方法一: 去重,双指针大法
class Solution {
public:
    vector<vector<int>> threeSum(vector<int>& nums) {
      // 三数之和; 关键是边界限制,防止重复!
      // 首先写出算法思想
     
     
      // 首先 无数或者<3返回null
      // 然后 排序数组
      // head = 0
      // 最后:for all
      //         边界:others > 0 returen;
      //         类边界:避免重复,和下一个是否重复  则break;
      //         最后,
      vector<vector<int>> answer = {};
      if(nums.empty() || nums.size() < 3){
        return answer;
      }
      sort(nums.begin(), nums.end());
      for(int i = 0; i < nums.size()-2 ;i++){
        // 去重操作
        if( i > 0 && nums[i] == nums[i-1] ){
          continue;
        }
        // 【答案】边界限制应该在这!  ——   加速时间
        if(nums[i] > 0){
          return answer; // 【改进】直接返回就好了,节省时间.
        }

        int head = i+1, tail = nums.size()-1;
        int target = 0-nums[i];
        while(head < tail){
          int others = nums[head]+nums[tail];
          // 边界限制
          // if(others < 0){
          //   return answer;
          // }

          //采用后 去重方法!否则容易  漏项。
          if(others > target){
            tail--;
            // while(head<tail && nums[tail] == nums[tail+1])  tail--;  //多余了
          }else if(others < target){
            head++;
            // while(head<tail && nums[head] == nums[head-1])  head++;  //多余了
          }else if(others == target){
            answer.push_back(vector<int> {nums[i], nums[head], nums[tail]});
            tail--;
            while(head<tail && nums[tail] == nums[tail+1])  tail--;  //head<tail && 
            head++;
            while(head<tail && nums[head] == nums[head-1])  head++;
          }

        }
      }
      return answer;
    }
};


//方法二: 3Sum--变2Sum  两层去 count()  


int main(){
  Solution s;
  // vector<int> c = {-1, 0, 1, 2, -1, -4};
  // vector<int> c = {0,0,0,0};
  vector<int> c = {34,55,79,28,46,33,2,48,31,-3,84,71,52,-3,93,15,21,-43,57,-6,86,56,94,74,83,-14,28,-66,46,-49,62,-11,43,65,77,12,47,61,26,1,13,29,55,-82,76,26,15,-29,36,-29,10,-70,69,17,49};
  vector<vector<int>> a = s.threeSum(c);
  for (int i = 0; i < a.size(); i++)
  {
    for(int j = 0;j<3;j++){
      cout << a[i][j] << " ";
    }
    cout << endl;
  }
  printf("Hello World!\n");
  system("pause");
  return 0;

总结与提升:

  1. 总体思想——在TwoSum 时使用【左右指针】进行快速遍历,其它KSum 部分使用暴力法——并注意 去除重复
  • 首先,判定是否 不存在或 <K ,则返回。
  • 然后,对数组进行排序,排序后固定K-2个数暴力遍历nums[i],num[j] ……
  • 再使用左右指针指向 除去nums[i],num[j] …… 后面的两端,数字分别为 nums[L]和 nums[R]
    - 如果 nums[i]大于 0,则三数之和必然无法等于 0,结束循环
  • 如果 nums[i] == nums[i−1],则说明该数字重复,会导致结果重复,所以应该跳过
    - 计算K个数的和 sum判断是否满足为 target,当 sum== target 时,满足则添加进结果集。nums[L] == nums[L+1]则会导致结果重复,应该跳过,L++; nums[R] == nums[R−1] 则会导致结果重复,应该跳过,R−− ;
    - 当 sum < target 时,L++
    - 当 sum > target 时,R−−
  • 时间复杂度:O(n^(k-1) ),n 为数组长度
  1. 关键伪代码和思考: 在于去除重复
//采用后 去重方法!否则容易  漏项。
// 
          if(others > target){
            tail--;
            // while(head<tail && nums[tail] == nums[tail+1])  tail--;  //多余了
          }else if(others < target){
            head++;
            // while(head<tail && nums[head] == nums[head-1])  head++;  //多余了
          }else if(others == target){
            answer.push_back(vector<int> {nums[i], nums[head], nums[tail]});
            tail--;
            while(head<tail && nums[tail] == nums[tail+1])  tail--;  //head<tail && 
            head++;
            while(head<tail && nums[head] == nums[head-1])  head++;
          }

而且要结合两数相加的特性—— 不会重复,两个加和唯一,所以直接两边 双指针一起同时加减即可 没有遗漏了。
3. 然后锦上添花:边界限制——想不对的就不加

// 【答案】边界限制应该在这!  ——   加速时间
        if(nums[i] > 0){
          return answer; // 【改进】直接返回就好了,节省时间.
        }

2、二分查找【扩展见 另一篇

/**
 * @description: 
 * @param {*}
 * @return {*}
 * @notes: 已经排好序的数组
 */
int binarySearch(int nums[], int target){
    int n = sizeof(nums) / sizeof(nums[0]);
    int left = 0;
    int right = n -1;
    // 查找所以等于
    while(left <= right){
        int mid = (left+right) / 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; // 表示未查找到。

}

3、翻转数组

int reverse(int nums[]){
    int n = sizeof(nums) / sizeof(nums[0]);
    int left = 0;
    int right = n-1;
    while(left<right){
        int temp = nums[left];
        nums[left]=nums[right];
        nums[right]=temp;
        left++, right--;
    }

}

4、滑动窗口【扩展 见另一篇总结

这也许是双指针技巧的最高境界了,如果掌握了此算法,可以解决一大类子字符串匹配的问题,不过「滑动窗口」算法比上述的这些算法稍微复杂些。

幸运的是,这类算法是有框架模板的,下篇文章就准备讲解「滑动窗口」算法模板,帮大家秒杀几道 LeetCode 子串匹配的问题

总结

以上,谢谢朋友们!
加油!

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值