【算法训练营 · 汇总篇】数组、链表、哈希表、字符串、栈与队列
文章目录
- 数组部分
- 数组理论基础回顾
- 二分查找
- 移除元素
- 有序数组的平方
- 长度最小的子数组
- 螺旋矩阵II
- 数组总结
- 链表部分
- 链表基础理论
- 移除链表元素
- 设计链表
- 反转链表
- 两两交换链表中的节点
- 删除链表的倒数第 N 个结点
- 链表相交
- 环形链表 II
- 链表总结
- 哈希表部分
- 哈希表理论基础
- 有效的字母异位词
- 两个数组的交集
- 快乐数
- 两数之和
- 四数相加
- 赎金信
- 三数之和
- 四数之和
- 字符串部分
- 反转字符串
- 反转字符串II
- 替换数字
- 反转字符串里的单词
- 右旋字符串
- KMP算法
- 双指针法总结
- 栈与队列部分
- Java相关理论基础
- 用栈实现队列
- 用队列实现栈
- 有效的括号
- 删除字符串中的所有相邻重复项
- 逆波兰表达式求值
- 滑动窗口最大值
- 前 K 个高频元素
数组部分
数组理论基础回顾
注意几个重点就行:
- 一维数组普遍在内存中都是连续分配的
- 下标从0开始
- 删除或者增添的时候需要移动其他元素(除了尾元素)
- 数组元素只能覆盖不会删除
- 数组内存空间会预分配,不够会走扩容机制,结束会被gc,不会随意的减少分配
二分查找
题目链接:704. 二分查找
算法可视化图解:二分查找原理
双指针法解题思路:
- 首先初始化前后指针,分别处于列表的两端
- 将两指针的中间节点作为比较元素(索引相加除2,向下取整)
- 如果比目标元素大,则将后指针移动到中间节点的前一个位置
- 如果比目标元素小,则将前指针移动到中间节点的后一个位置
- 如果与目标元素相同,则找到
- 如此循环往复直到前后指针位置异常(前指针在后)
以下是结题代码:
class Solution {public int search(int[] nums, int target) {int head = 0;int end = nums.length - 1;int middle = (head + end) / 2;while (head <= end) {if (nums[middle] < target) {head = middle + 1;middle = (head + end) / 2;}else if(nums[middle] > target) {end = middle - 1;middle = (head + end) / 2;}else {return middle;}}return -1;}
}
移除元素
题目链接:27. 移除元素
双指针法解题思路:
- 首先将双指针都指向表头
- head指针作为填充指针,从表头开始填充
- end指针作为遍历指针,遍历数组
- 开始遍历数组
- 遇到非移除元素通过head指针填充,然后head指针后移,同时计数
- 如果是要移除元素,则进入下一次循环
class Solution {public int removeElement(int[] nums, int val) {int head = 0;int count = 0;for (int i = 0; i < nums.length; i++) {if(nums[i] == val) {continue;}nums[head++] = nums[i];count++;}return count;}
}
有序数组的平方
题目链接:977. 有序数组的平方
此题可以暴力排序但并没有双指针法简洁,因为本题是一个有序数组,那么平方之后的最大值只可能在两端出现。
双指针法解体思路:
- 初始化双指针指向数组的头尾,初始化一个新数组
- 比较两指针所指元素的平方大小
- 如果左指针的更大,则将左指针的元素平方填充到新数组,然后左指针右移
- 如果右指针的更大,则将右指针的元素平方填充到新数组,然后右指针左移
- 如此循环往复直到左右指针位置异常
代码如下:
class Solution {public int[] sortedSquares(int[] nums) {int head = 0;int end = nums.length -1;int[] result = new int[nums.length];int write = nums.length -1;while(head <= end) {int headNum = (int)Math.pow(nums[head],2);int endNum = (int)Math.pow(nums[end],2);if(headNum >= endNum) {result[write--] = headNum;head++;}else {result[write--] = endNum;end--;}}return result;}
}
长度最小的子数组
题目链接:209. 长度最小的子数组
滑动窗口法的解题思路:
- 首先初始化双指针,都指向数组头部
- end指针依次向后滑,每向后滑一项就加一项,直到满足要求达到的大小为止
- end指针不动,head指针向后滑,每向后滑一项就减一项,这也就是在尝试缩小窗口大小,直到不满足大小为止
- 更新窗口的最小长度
- 如此循环往复直到end指针未知异常(超出数组范围)
- 最后返回窗口的最小长度即可
- 这里有一个容易遗漏的情况就是,数组的所有元素加起来都没达到要求,这个时候就要返回0
class Solution {public int minSubArrayLen(int target, int[] nums) {int head = 0;int total = 0;int minLength = Integer.MAX_VALUE;for(int end = 0; end < nums.length; end++) {total += nums[end];if(total < target) {if (head == 0 && end == nums.length - 1) return 0;continue;}while (total - nums[head] >= target) total -= nums[head++];int currentLength = end - head + 1;if(currentLength < minLength) minLength = currentLength;}return minLength;}
}
螺旋矩阵II
题目链接: 59. 螺旋矩阵 II
本题首先观察题目特点:
- 填充路线都是在一个方向上进行到底才转向
- 往哪转?可以从向量的角度来看,如下图
- 这张图中通过单位向量的方式,描述了每次转向的方向(同时也代表了每次移动的坐标变化量)
- 我们可以总结出每次转向都是(x,y) --> (y,-x)
接下来开始编码:
class Solution {public int[][] generateMatrix(int n) {int[][] result = new int[n][n];int x = 0;int y = 0;int direct_x = 0;int direct_y = 1;for (int i = 1; i <= n * n; i++) {result[x][y] = i;int try_x = x + direct_x;int try_y = y + direct_y ;if (try_x > n - 1 || try_y > n - 1 || try_x < 0 || try_y < 0 || result[try_x][try_y] != 0) {int temp = direct_y;direct_y = -direct_x;direct_x = temp;}x += direct_x;y += direct_y;}return result;}
}
转向的边界判断可以使用取模的方式简化
数组总结
数组的相关算法题,比较常用的就是四种方法:
- 二分法
- 双指针法: 两个指针的动向比较灵活,但不能违背指针的前后次序
- 滑动窗口法: 可以理解为双指针法的变种,两个指针具有单调性,均往一个方向走,区间的变化是一种收缩,满足元素剔除的思想
- 行为模拟: 一定要弄清楚题目的特点,涉及到多维数组,可以从数学的角度来看待,通过建系的方法,把行为转化为坐标的变化从而进行解题
链表部分
链表基础理论
几个需要关注的知识点:
- 链表与数组的不同之处就在于:链表在内存中不一定是连续的,可以是离散存储的,他们之间通过指针进行连接。这也就决定了链表是不能随机查询的,只能通过指针顺藤摸瓜进行顺序查询。
- 在数组中删除和添加操作会影响到后续的所有元素,而链表是通过指针链接,我们在删除和添加的时候,是对指针所指元素进行修改。
- 数组的长度在初始化的时候就已经定下来了,而链表的长度是可以不固定的,因为其离散的存在于内存之中。
- 链表的增加、删除操作一般可以引入一个虚拟头节点完成,使逻辑更加简洁,可以无需考虑头节点的更新以及边界情况的处理。
- 要是有虚拟头节点,头节点就固定为虚拟头节点的下一个节点,所有的插入和删除操作都能以统一的方式进行处理。
Java语言的单链表定义:
public class ListNode {// 结点的值int val;// 下一个结点ListNode next;// 节点的构造函数(无参)public ListNode() {}// 节点的构造函数(有一个参数)public ListNode(int val) {this.val = val;}// 节点的构造函数(有两个参数)public ListNode(int val, ListNode next) {this.val = val;this.next = next;}
}
移除链表元素
题目链接:203. 移除链表元素
删除逻辑:
- 首先为链表增添一个虚拟头节点
- 然后从头节点开始遍历整个链表找到要删除的元素
- 执行删除逻辑必不可少的两个节点就是删除节点的前缀节点和后缀节点
- 然后通过指针的修改完成链表的删除操作
代码如下:
/*** Definition for singly-linked list.* public class ListNode {* int val;* ListNode next;* ListNode() {}* ListNode(int val) { this.val = val; }* ListNode(int val, ListNode next) { this.val = val; this.next = next; }* }*/
class Solution {public ListNode removeElements(ListNode head, int val) {ListNode virtualHead = new ListNode(-1,head);ListNode pointer = virtualHead;while(pointer != null && pointer.next != null) {if(pointer.next.val == val) {pointer.next = pointer.next.next;continue;}pointer = pointer.next;}return virtualHead.next;}
}
设计链表
设计链表:707. 设计链表
逻辑不是很难,主要是调试解决一些细枝末节的问题。重点主要是了解链表的删除、添加逻辑(在有头节点的情况下)
代码如下:
class MyLinkedList {int val;MyLinkedList next;int length;public MyLinkedList() {val = -1;next = null;length = 0;}public MyLinkedList(int val,MyLinkedList next) {this.val = val;this.next = next;}public int get(int index) {if(index >= length || index < 0) {return -1;}int count = index + 2;MyLinkedList pointer = this;while(count > 0) {if(count == 1) return pointer.val;count -= 1;pointer = pointer.next;}return -1;}public void addAtHead(int val) {MyLinkedList addNode = new MyLinkedList(val,this.next);this.next = addNode;length += 1;}public void addAtTail(int val) {MyLinkedList pointer = this;while(pointer.next != null) pointer = pointer.next;MyLinkedList addNode = new MyLinkedList(val,null);pointer.next = addNode;length += 1;}public void addAtIndex(int index, int val) {if(index > length || index < 0) {return;}else if(index == length) {addAtTail(val);}else if(index == 0){addAtHead(val);}else {int count = index + 1;MyLinkedList pointer = this;while(count > 0 && pointer.next != null) {if(count == 1) {MyLinkedList addNode = new MyLinkedList(val,pointer.next);pointer.next = addNode;}count -= 1;pointer = pointer.next;}length += 1;}}public void deleteAtIndex(int index) {if(index >= length || index < 0) {return;}else {int count = index + 1;MyLinkedList pointer = this;while(count > 0 && pointer.next != null) {if(count == 1) {pointer.next = pointer.next.next;}count -= 1;pointer = pointer.next;}length -= 1;}}
}
反转链表
题目链接:206. 反转链表
解题逻辑:在遍历链表的同时,使用头插法创建一个新链表,得到的新链表就是反转后的链表。
代码如下:
class Solution {public ListNode reverseList(ListNode head) {ListNode reverseResultVirtualHead = new ListNode();ListNode pointer = head;while(pointer != null) {ListNode currentNode = new ListNode(pointer.val,null);currentNode.next = reverseResultVirtualHead.next;reverseResultVirtualHead.next = currentNode;pointer = pointer.next;}return reverseResultVirtualHead.next;}
}
两两交换链表中的节点
题目链接:24. 两两交换链表中的节点
算法逻辑:
- 添加一个虚拟头节点
- 初始化一个交换指针,代表每次交换指针的后两个节点,该指针从虚拟头节点开始移动
- 接下来就是交换步骤
- 先将交换指针的后面第一个节点(简称后1节点)以及交换指针的后3节点存起来
- 将交换指针的后2节点与交换指针节点进行缝合
- 将存起来的后1节点与后2节点进行缝合
- 在将存起来的后3节点与后1节点进行缝合
- 如此交换直到交换指针节点为null,或者后1节点为null,或者后2节点为null,因为必须要有两个节点才能交换
/*** Definition for singly-linked list.* public class ListNode {* int val;* ListNode next;* ListNode() {}* ListNode(int val) { this.val = val; }* ListNode(int val, ListNode next) { this.val = val; this.next = next; }* }*/
class Solution {public ListNode swapPairs(ListNode head) {if(head == null || head.next == null) return head;ListNode virtualHead = new ListNode(-1,head);ListNode exchangePointer = virtualHead;while(exchangePointer != null && exchangePointer.next != null && exchangePointer.next.next != null) {ListNode temp1 = exchangePointer.next;ListNode temp3 = temp1.next.next;exchangePointer.next = exchangePointer.next.next;exchangePointer.next.next = temp1;exchangePointer.next.next.next = temp3;exchangePointer = exchangePointer.next.next;}return virtualHead.next;}
}
删除链表的倒数第 N 个结点
题目链接:19. 删除链表的倒数第 N 个结点
逻辑思路:
这道题既然是删除倒数的第n个节点,那么我们可以将链表反转,然后再删除第n个,最后再把链表反转回去即可。我们前几天的训练营中做过移除链表元素、翻转链表等算法题,所以这里直接把相关方法移植过来即可。
代码:
/*** Definition for singly-linked list.* public class ListNode {* int val;* ListNode next;* ListNode() {}* ListNode(int val) { this.val = val; }* ListNode(int val, ListNode next) { this.val = val; this.next = next; }* }*/
class Solution {public ListNode removeNthFromEnd(ListNode head, int n) {ListNode reverseList = reverseList(head);ListNode deleteList = deleteAtCount(reverseList,n);return reverseList(deleteList);}public ListNode reverseList(ListNode head) {ListNode reverseResultVirtualHead = new ListNode();ListNode pointer = head;while(pointer != null) {ListNode currentNode = new ListNode(pointer.val,null);currentNode.next = reverseResultVirtualHead.next;reverseResultVirtualHead.next = currentNode;pointer = pointer.next;}return reverseResultVirtualHead.next;}public ListNode deleteAtCount(ListNode head,int count) {ListNode virtualHead = new ListNode(-1,head);ListNode pointer = virtualHead;while(count > 0 && pointer.next != null) {if(count == 1) {pointer.next = pointer.next.next;}count -= 1;pointer = pointer.next;}return virtualHead.next;}
}
链表相交
题目链接:面试题 02.07. 链表相交
算法逻辑:
读完题目之后可以发现一个特点那就是若链表相交,那么必有公共后缀!我们若想找到两个链表的公共后缀那么第一想法肯定就是反向遍历。但是这是一个单向链表,直接反向遍历无法实现,这个时候我们就可以借助于栈结构来完成公共后缀的比对。
/*** Definition for singly-linked list.* public class ListNode {* int val;* ListNode next;* ListNode(int x) {* val = x;* next = null;* }* }*/
public class Solution {public ListNode getIntersectionNode(ListNode headA, ListNode headB) {if(headA == null || headB == null) return null;ListNode pointerA = headA;ListNode pointerB = headB;Stack<ListNode> a = new Stack<ListNode>();Stack<ListNode> b = new Stack<ListNode>();while(pointerA != null) {a.push(pointerA);pointerA = pointerA.next;}while(pointerB != null) {b.push(pointerB);pointerB = pointerB.next;}ListNode nodeA = a.pop();ListNode nodeB = b.pop();ListNode result = null;while(nodeA == nodeB) {result = nodeA;if (a.empty() || b.empty()) break;nodeA = a.pop();nodeB = b.pop();}return result;}
}
环形链表 II
题目链接: 142. 环形链表 II
解题思路:
判断是否成环,换一句话说也就是在遍历链表的时候看是否有重复的节点,第一个重复的节点就是成环的起点。我们可以利用set数据结构,每遍历到一个新节点就看set中是否有相同节点,如果没有就添加到set中,如果有则说明该节点就是成环节点。遍历完了都没有重复节点,则说明不成环。
代码:
/*** Definition for singly-linked list.* class ListNode {* int val;* ListNode next;* ListNode(int x) {* val = x;* next = null;* }* }*/
public class Solution {public ListNode detectCycle(ListNode head) {Set<ListNode> nodeSet = new HashSet();ListNode pointer = head;while(pointer != null) {if(nodeSet.contains(pointer)) return pointer;nodeSet.add(pointer);pointer = pointer.next;}return null;}
}
链表总结
链表的关键点就在于:
- 增加、删除的时候可以引入虚拟头节点,让逻辑更加统一,不用单独考虑一些边界情况
- 如果是遍历等不会修改链表结构的操作则无需引入虚拟头节点
- 增加删除的重点就在于增加、删除元素的前后链要是可见的,才能完成相关的节点缝合工作。这就需要我们引入一些中间变量来存储这些前后链.
哈希表部分
哈希表理论基础
几个值得关注的知识点:
- hash表用于快速的判断元素是否存在(空间换时间)
- 其原理就是将数据通过散列函数映射到bucket中,如果发生hash碰撞,常见的处理方法有拉链法、线性探测法等等
- 在Java中几种常见的与hash表相关的数据结构:
- 数组:判断、计数情况可使用
- HashSet:存单个元素
- LinkedHashSet:在HashSet的基础上增添了顺序性
- HashMap:存键值对,线程不安全
- LinkedHashMap:在hashmap的基础上增加了添加元素的顺序性
- Hashtable:线程安全,一般不怎么用,使用ConcurrentHashMap替代
有效的字母异位词
题目链接:242. 有效的字母异位词
解题逻辑:
- 要满足两个单词异位,换句话说就是要两个单词组成的字母以及字母的数量要一样。
- 我们可以遍历第一个字符串,将第一个字符串的所有字符加入到hashmap中,key使字母,value是字母的个数
- 对第二个字符串做同样的操作
- 比对两个hashmap
- 长度首先要一样
- 再比较每一项
代码如下:
class Solution {public boolean isAnagram(String s, String t) {char[] ss = s.toCharArray();Map<Character,Integer> scount = new HashMap<>();for(char word : ss) {if(scount.containsKey(word)) {scount.put(word,scount.get(word) + 1);}else {scount.put(word,1);}}char[] tt = t.toCharArray();Map<Character,Integer> tcount = new HashMap<>();for(char word : tt) {if(tcount.containsKey(word)) {tcount.put(word,tcount.get(word) + 1);}else {tcount.put(word,1);}}if(scount.size() != tcount.size()) return false;for(Map.Entry<Character, Integer> entry: scount.entrySet()) {Character key = entry.getKey();Integer value = entry.getValue();if(tcount.get(key) == null) return false;if(!tcount.get(key).equals(value)) return false;}return true;}
}
这种方法比暴力解法好一点点,但是也显得非常臃肿,原因就是实现hash表的数据结构并不是最优解。我们仔细观察这道题可以发现既然是英文单词,那么最多只有26个字母。那么我们可以将26个字母散列到长度为26的数组中,然后每个位置上对字母进行计数。
代码如下:
class Solution {public boolean isAnagram(String s, String t) {int[] record = new int[26];for (int i = 0; i < s.length(); i++) record[s.charAt(i) - 'a']++;for (int i = 0; i < t.length(); i++) record[t.charAt(i) - 'a']--;for (int count: record) if (count != 0) return false;return true; }
}
这里我们可以思考一下将数组作为简单的hash表的注意点:
- 核心:将数组的索引作为key
- 使用场景
- 能将要散列的属性与数组索引构成联系
- key为密集分布的整数,并且范围小且已知(如果键稀疏则会造成大量的内存空间浪费,范围无法确定数组是不能动态扩容的)
两个数组的交集
题目链接:349. 两个数组的交集
最容易想到的就是将两个数组转化为set,然后直接取交集:
class Solution {public int[] intersection(int[] nums1, int[] nums2) {Set<Integer> set1 = Arrays.stream(nums1).boxed().collect(Collectors.toSet());Set<Integer> set2 = Arrays.stream(nums2).boxed().collect(Collectors.toSet());set1.retainAll(set2);return set1.stream().mapToInt(Integer::intValue).toArray();}
}
答案是这么写的:
class Solution {public int[] intersection(int[] nums1, int[] nums2) {if (nums1 == null || nums1.length == 0 || nums2 == null || nums2.length == 0) {return new int[0];}Set<Integer> set1 = new HashSet<>();Set<Integer> resSet = new HashSet<>();//遍历数组1for (int i : nums1) {set1.add(i);}//遍历数组2的过程中判断哈希表中是否存在该元素for (int i : nums2) {if (set1.contains(i)) {resSet.add(i);}}//方法1:将结果集合转为数组return resSet.stream().mapToInt(Integer::intValue).toArray();}
}
两种方法的时间复杂度是一样的都是O(n),答案相当于只是把retainAll方法的逻辑展开了。如果还想优化效率,还是得依靠数组,因为本题限制了数组数据的范围,所以才考虑到可以使用这种方法:
class Solution {public int[] intersection(int[] nums1, int[] nums2) {int[] record1 = new int[1002];int[] record2 = new int[1002];for(int num : nums1) record1[num] += 1;for(int num : nums2) record2[num] += 1;List<Integer> resultList = new ArrayList<>();for(int i = 0;i < 1002;i++) if(record1[i] > 0 && record2[i] > 0) resultList.add(i);return resultList.stream().mapToInt(Integer::intValue).toArray();}
}
快乐数
题目链接:202. 快乐数
解题逻辑:
本题直接按照题目的步骤写代码即可,但是要弄明白,每一个数字在执行的过程中只可能有两种结果:
- 无限循环,返回false
- 满足快乐数要求,返回true
既然无限循环返回false,那么我们就可以将结果进行一个记录,有重复直接返回false
代码如下:
class Solution {public boolean isHappy(int n) {int sum = n;Set<Integer> record = new HashSet<>();while(sum != 1) {int num = sum;sum = 0;while(num >= 1){int temp = num % 10;sum += temp * temp;num /= 10;}if(record.contains(sum)) return false;record.add(sum);}return true}
}
两数之和
题目链接:1. 两数之和
首先暴力解法很容易想到,这种方法的时间复杂度是N方:
class Solution {public int[] twoSum(int[] nums, int target) {for(int i = 0;i < nums.length;i++) {for(int j = i + 1;j < nums.length;j++) {if(nums[i] + nums[j] == target) return new int[]{i,j};}}return null;}
}
如果要继续优化,我们就要想办法使用单层循环解决问题,一开始我想到遍历数组,把每个元素塞到hashmap中,key是数组元素,value该元素在数组中的索引,然后再次遍历数组,在map中寻找target - 遍历元素。但是这样的话无法处理target是两个相同元素的加和,因为hashmap的key是不可能重复的。既然这样的话那么我们就只能一边遍历数组,一边添加到hashmap中。
解题逻辑如下:
- 初始化一个hashmap用以存储已经存在的其中一个加数
- 遍历数组,在hashmap中寻找target - 遍历元素是否存在
- 如果存在,通过key查找value结合当前遍历索引返回答案
- 如果不存在,把该值添加到hashmap中,表示这个加数存在,可供后续使用
class Solution {public int[] twoSum(int[] nums, int target) {Map<Integer,Integer> records = new HashMap<>();for(int i = 0;i < nums.length;i++) {int need = target - nums[i];if(records.get(need) == null) records.put(nums[i],i);else return new int[]{i,records.get(need)};}return null;}
}
四数相加
题目链接:454. 四数相加 II
这个题注意它只需要给出次数,而不是元组。所以我们可以分治。将前两个数组的加和情况使用map存储起来,再将后两个数组的加和情况使用map存储起来,key存和,value存出现的次数。得到两个map之后,我们遍历其中一个map, 看另一个map中是否有和为0的情况,有就相加value,最后接可以得出答案。
在这种思路的基础上,我们可以继续优化代码,例如我们在统计后两个数组的同时,就已经可以将需要的和在前两个数组的map中找出来,然后把次数进行相加。
代码如下:
class Solution {public int fourSumCount(int[] nums1, int[] nums2, int[] nums3, int[] nums4) {Map<Integer,Integer> records = new HashMap<>();for(int i = 0;i < nums1.length;i++) {for(int j = 0;j < nums2.length;j++) {records.put(nums1[i] + nums2[j],records.getOrDefault(nums1[i] + nums2[j],0) + 1);}}int result = 0;for(int i = 0;i < nums3.length;i++) {for(int j = 0;j < nums4.length;j++) {Integer count = records.get(0 - nums3[i] - nums4[j]);if(count != null) result += count;}}return result;}
}
赎金信
题目链接:383. 赎金信
二十六个字母,计数,第一反应就要想到数组。解题思路如下:
- 用数组统计第一个单词的个数
- 然后遍历第二个单词,在数组中相应位置进行减法运算
- 遍历数组
- 如果存在大于0的数返回false
- 不存在则返回true
class Solution {public boolean canConstruct(String ransomNote, String magazine) {int[] records = new int[26];for (int i = 0; i < ransomNote.length(); i++) records[ransomNote.charAt(i) - 'a']++;for (int i = 0; i < magazine.length(); i++) records[magazine.charAt(i) - 'a']--;for (int i = 0; i < records.length; i++) if(records[i] > 0 ) return false;return true;}
}
三数之和
题目链接:15. 三数之和
解题思路:
看到这一题我们肯定会不自觉地拿它和两数之和进行比较,我们是否能借助两数之和的思想来完成这一题?首先我们回顾一下两数之和的思想。在两数之和中,我们是遍历数组,每遍历一个元素,就看target - 该元素 是否已经出现过(也就是是否在hash表中),如果在直接返回,如果不在就把这个元素添加到hash表中,代表该元素出现过,为后面的元素服务。
在三数相加中,我们尝试沿用这种思路(先不直接到位,后面还会添加新逻辑):
- 使用双层for循环遍历数组,外层循环相当于固定一个数,内层for循环沿用两数相加的逻辑
- 初始化一个hashset,用来存已经存在的数,外层循环的以固定值不需要存
- 内存循环遍历数组,寻找0 - nums[head]- nums[end] 是否存在于hashset中
- 如果存在那么该数组添加到答案列表中
- 如果不存在继续遍历
- 外层循环每完成一次清空set
- 最后返回答案集合
代码如下:
class Solution {public List<List<Integer>> threeSum(int[] nums) {List<List<Integer>> result = new ArrayList<>();HashSet<Integer> set = new HashSet<>();for (int i = 0; i < nums.length; i++) {for (int j = i + 1; j < nums.length; j++) {int need = -nums[i] - nums[j];if (set.contains(need)) {result.add(Arrays.asList(nums[i], nums[j], need));} else {set.add(nums[j]);}}set.clear();}return result;}
}
这个代码完成了基本的功能但是还差本题的一个重点那就是去重。
就比如题目的这个用例:[-1,0,1,2,-1,-4]
如下两种情况就会重复:
- i指向-1,j指向1,set里有0,这组会返回
- i指向0,j指向-1,set里有1,这组也会返回
我们可以尝试排序解决这个问题:
排序之后还是这个用例就变为:[-4,-1,-1,0,1,2]
外层循环在固定到两个-1的时候肯定会发生重复,所以我们可以添加一个条件,外层循环固定的数字和上一次相同时直接跳过:
class Solution {public List<List<Integer>> threeSum(int[] nums) {Arrays.sort(nums);List<List<Integer>> result = new ArrayList<>();HashSet<Integer> set = new HashSet<>();for (int i = 0; i < nums.length; i++) {if (i > 0 && nums[i] == nums[i - 1]) {continue;}for (int j = i + 1; j < nums.length; j++) {int need = -nums[i] - nums[j];if (set.contains(need)) {result.add(Arrays.asList(nums[i], nums[j], need));} else {set.add(nums[j]);}}set.clear();}return result;}
}
改完之后发现这个测试用例过不了:
原因就是当内层的两数相加满足之后,内层的下一次循环还是相同的数,那么相当于把这一组答案又加了一遍,那么我们针对这个情况进行改进:
class Solution {public List<List<Integer>> threeSum(int[] nums) {Arrays.sort(nums);List<List<Integer>> result = new ArrayList<>();HashSet<Integer> set = new HashSet<>();for (int i = 0; i < nums.length; i++) {if (i > 0 && nums[i] == nums[i - 1]) {continue;}Integer flag = null;for (int j = i + 1; j < nums.length; j++) {if(Integer.valueOf(nums[j]).equals(flag)) continue;flag = null;int need = -nums[i] - nums[j];if (set.contains(need)) {result.add(Arrays.asList(nums[i], nums[j], need));flag = nums[j];} else {set.add(nums[j]);}}set.clear();}return result;}
}
我们最后总结一下这道题:
这道题在沿用了我们前面两数之和的思想之后,会存在一个去重问题:
- 外层重复:也就是当外层循环固定的数字和上一次相同时此次循环直接跳过
- 内层重复:也就是当内层的两数相加满足之后,内层的下一次循环还是相同的数。这个时候我们可以在每次三数之和满足条件之后,将内层此次的值记录一下,相邻的下一次循环与此次的值一样就跳过此次内循环。
当然此题也可以使用双指针法来做,逻辑上更为简单,代码在此处不多做赘述。
四数之和
题目链接:18. 四数之和
这题使用双指针法进行解题:
- 将数组进行排序
- 首先使用两层的嵌套循环,固定两个数
- 然后再使用双指针left、right确定最后两个数
- 将四个数字相加
- 如果大于目标,right指针左移
- 如果小于目标,left指针右移
- 如果达到目标,left指针右移,right指针左移
class Solution {public List<List<Integer>> fourSum(int[] nums, int target) {Arrays.sort(nums);List<List<Integer>> result = new ArrayList<>();for(int i = 0;i < nums.length;i++) {for(int j = i + 1;j < nums.length;j++) {int left = j + 1;int right = nums.length - 1;while(left < right && left < nums.length) {int sum = nums[i] + nums[j] + nums[left] + nums[right];if(sum > target) right--;else if(sum < target) left++;else {result.add(Arrays.asList(nums[i], nums[j], nums[left++],nums[right--]));}}}}return result;}
}
接下来进行去重:
发生这种情况就是因为外层的双层循环固定的两个数字重复,我们添加去重的代码:
class Solution {public List<List<Integer>> fourSum(int[] nums, int target) {Arrays.sort(nums);List<List<Integer>> result = new ArrayList<>();for(int i = 0;i < nums.length;i++) {if (i > 0 && nums[i] == nums[i - 1]) continue;for(int j = i + 1;j < nums.length;j++) {if (j > i + 1 && nums[j] == nums[j - 1]) continue;int left = j + 1;int right = nums.length - 1;while(left < right && left < nums.length) {int sum = nums[i] + nums[j] + nums[left] + nums[right];if(sum > target) right--;else if(sum < target) left++;else {result.add(Arrays.asList(nums[i], nums[j], nums[left++],nums[right--]));}}}}return result;}
}
这个去重的逻辑就是:
if (i > 0 && nums[i] == nums[i - 1]) continue;
因为数组是按大小排序的,如果第一个固定的数不变,那么其他三个数不管怎么样,都只会与target相等(这个情况已经存在需要去重)或者比target大,所以这个循环可以直接跳过if (j > i + 1 && nums[j] == nums[j - 1]) continue;
逻辑类似
执行之后有一个测试样例还是没过:
造成这个情况的原因是因为,左右指针同时内缩的时候如果元素不变也会发生重复,我们继续往里面添加去重逻辑:
class Solution {public List<List<Integer>> fourSum(int[] nums, int target) {Arrays.sort(nums);List<List<Integer>> result = new ArrayList<>();for(int i = 0;i < nums.length;i++) {if (i > 0 && nums[i] == nums[i - 1]) continue;for(int j = i + 1;j < nums.length;j++) {if (j > i + 1 && nums[j] == nums[j - 1]) continue;int left = j + 1;int right = nums.length - 1;while(left < right && left < nums.length) {long sum = (long)nums[i] + nums[j] + nums[left] + nums[right];if(sum > target) right--;else if(sum < target) left++;else {result.add(Arrays.asList(nums[i], nums[j], nums[left],nums[right]));while (right > left && nums[right] == nums[right - 1]) right--;while (right > left && nums[left] == nums[left + 1]) left++;left++;right--;}}}}return result;}
}
注意:测试用例中有一个例子考察的是四数相加的范围超出了int的最大表达值,所以四数相加的和sum要使用long来存储
字符串部分
反转字符串
题目链接:344. 反转字符串
双指针法,两个指针的元素直接调转即可
class Solution {public void reverseString(char[] s) {int head = 0;int end = s.length - 1;while(head < end) {char temp = s[head];s[head++] = s[end];s[end--] = temp;}}
}
反转字符串II
题目链接:541. 反转字符串 II
双指针法代码逻辑:
- 初始化head、end双指针,两指针相距2k,作为操作区间
- 在操作区间中反转前k个字符
- 移动指针,head移到end,end往后移2k,继续上述操作
- 直到end指针超出界限
- 此时将多余部分分情况反转
- 少于k全部反转
- 大于k,反转前k个
代码如下:
class Solution {public String reverseStr(String s, int k) {int head = 0;int end = head + 2 * k;char[] charArray = s.toCharArray();while(end < s.length()) {reverseString(charArray,head,head + k - 1);head = end;end += 2 * k; }int rest = s.length() - head;if(rest >= k) {reverseString(charArray,head,head + k - 1);} else {reverseString(charArray,head,s.length() -1);}return new String(charArray);}public void reverseString(char[] s,int start,int end) {while(start < end) {char temp = s[start];s[start++] = s[end];s[end--] = temp;}}
}
替换数字
题目链接:54. 替换数字
解题逻辑直接遍历使用StringBuilder拼接即可:
import java.util.*;public class Main {public static void main(String[] args) {Scanner scanner = new Scanner(System.in);String input = scanner.nextLine(); // 读取一整行字符串Set<Character> nums = new HashSet<>(Arrays.asList('1','2','3','4','5','6','7','8','9','0'));StringBuilder result = new StringBuilder();for(int i = 0;i < input.length();i++) {if (nums.contains(input.charAt(i))) result.append("number");else result.append(input.charAt(i));}System.out.println(result);scanner.close();}
}
当然这么写就绕过了此题的精髓。
这道题想要体现的点在于:很多数组填充类的问题,其做法都是先预先给数组扩容带填充后的大小,然后在从后向前进行操作。
字符串的本质就是char型数组,所以这个方法当然可以沿用,其好处在于:
- 避免数据覆盖:如果从前往后填充,在填充过程中可能会覆盖尚未处理的数据。而从后向前填充,可以确保在填充新元素时,不会影响到前面尚未处理的元素,从而保证了数据的完整性和正确性。
- 提高效率:从后向前操作可以减少元素的移动次数。在数组扩容后,若从前往后填充,每插入一个新元素,后面的元素都需要向后移动一位,时间复杂度较高。而从后向前填充,只需将新元素直接放置在合适的位置,无需频繁移动其他元素,提高了填充操作的效率。
代码如下:
import java.util.Scanner;public class Main {public static String replaceNumber(String s) {int count = 0; // 统计数字的个数int sOldSize = s.length();for (int i = 0; i < s.length(); i++) {if(Character.isDigit(s.charAt(i))){count++;}}// 扩充字符串s的大小,也就是每个空格替换成"number"之后的大小char[] newS = new char[s.length() + count * 5];int sNewSize = newS.length;// 将旧字符串的内容填入新数组System.arraycopy(s.toCharArray(), 0, newS, 0, sOldSize);// 从后先前将空格替换为"number"for (int i = sNewSize - 1, j = sOldSize - 1; j < i; j--, i--) {if (!Character.isDigit(newS[j])) {newS[i] = newS[j];} else {newS[i] = 'r';newS[i - 1] = 'e';newS[i - 2] = 'b';newS[i - 3] = 'm';newS[i - 4] = 'u';newS[i - 5] = 'n';i -= 5;}}return new String(newS);};public static void main(String[] args) {Scanner scanner = new Scanner(System.in);String s = scanner.next();System.out.println(replaceNumber(s));scanner.close();}
}
反转字符串里的单词
题目链接:151. 反转字符串中的单词
双指针法解题逻辑
- head指针遍历字符串
- 遍历到单词首单词,生成end指针移动到单词尾部
- 遇到完整单词收集,压入栈中
- head指针移动到end指针处
- 遍历完之后
- 通过出栈还原字符串
代码如下:
class Solution {public String reverseWords(String s) {int head = 0;Deque<String> strs = new ArrayDeque<String>();int count = 0;while(head < s.length()) {if(s.charAt(head) == ' ') {head++;continue;}int end = head;while(end < s.length() && s.charAt(end) != ' ') end++;strs.push(s.substring(head,end));count++;head = end + 1;}StringBuilder result = new StringBuilder();while(!strs.isEmpty()) {if(count-- == 1) result.append(strs.pop());else result.append(strs.pop() + " ");}return result.toString();}
}
右旋字符串
题目链接:55. 右旋字符串(第八期模拟笔试)
双指针法解题逻辑:
- head指针指向str头部,end指针指针在尾部
- end指针逆着走k步
- 截取前一部分字符串与后一部分字符串拼接即可
import java.util.Scanner;public class Main {public static void main(String[] args) {Scanner scanner = new Scanner(System.in);// 读取右旋转的位数 kint k = scanner.nextInt();scanner.nextLine(); // 消耗掉换行符// 读取需要旋转的字符串 sString s = scanner.nextLine();int head = 0;int end = s.length() - k;System.out.println(s.substring(end,s.length()) + s.substring(head,end));}
}
KMP算法
关于kmp算法的理解可以看我的另外一篇文章:KMP算法详解,能认字就能搞懂
题目链接:28. 找出字符串中第一个匹配项的下标
解题逻辑:
- 首先创建模式串的next数组
- 创建双指针一个指向主串,另一个指向模式串
- 如果不相同,则模式串的指针根据next数组进行回退
- 如果两个指针所指字符相同,则双指针都向前进一位
- 要注意如果要回退一定是先回退再比较
- 当模式串的指针指向模式串尾部后一位的时候代表找到了
- 此时把指向主串的指针对应的下标减去模式串的长度再加1就是模式串首次出现的下标
- 如果主串遍历完了都没有达到要求则表示没找到,返回-1
代码如下:
class Solution {public int strStr(String haystack, String needle) {int[] next = new int[needle.length()];builtNums(next,needle);int j = 0;for(int i = 0;i < haystack.length();i++) {while(haystack.charAt(i) != needle.charAt(j) && j > 0) {j = next[j - 1];}if(haystack.charAt(i) == needle.charAt(j)) {j++;if(j == needle.length()) return i - j + 1;}}return -1;}public void builtNums(int[] next,String needle){//创建双指针int j = 0;next[j] = 0;//创建next数组for (int i = 1;i < next.length;i++) {while(needle.charAt(i) != needle.charAt(j) && j > 0) j = next[j - 1];if (needle.charAt(i) == needle.charAt(j)) j++;next[i] = j;}}
}
双指针法总结
这种方法在一些线性结构上使用的比较多例如:数组、链表、字符串
要明确双指针法的灵魂就在于:使用双指针将两个for循环才能完成的任务使用一个for循环就可以完成!
比较常见的两种形式:
- 双指针一头一尾,同时向中间逼近(例如我们前面的反转字符串、n数之和等题)
- 双指针都在头部,职责不同,其中一个为循环变量(例如我们前面的移除数组元素等题)
栈与队列部分
Java相关理论基础
-
对于栈:在java中有直接对应的实现类Stack,但是已经过时,效率较低使用synchronized上锁保证线程安全。所以一般使用ArrayDeque或LinkedList来代替,对应的接口方法:
- push
- pop
- peak
- isEmpty
-
对于队列:在java中也可以使用LinkedList(队列中允许null出现的选用)、ArrayDeque(队列中没有null元素的时候选用)来实现,对应的接口方法有(返回特殊值就是操作失败时会返回 null 或者 false):
-
在 Java 里,Deque是一个接口,其全称为 “Double Ended Queue”,也就是双端队列。它继承自Queue接口,支持在队列的两端(头部和尾部)进行元素的插入、删除和获取操作。与普通队列(遵循 FIFO 原则)不同,双端队列对元素的插入和删除没有位置限制,使用起来更加灵活。Deque接口常见的实现类有ArrayDeque和LinkedList。ArrayDeque基于动态数组实现,而LinkedList基于链表实现。
-
一句话说java中栈与队列都可以使用双端队列来实现
用栈实现队列
题目链接:232. 用栈实现队列
解题思路:
- 初始化两个栈
- 其中一个栈1,专门用来实现队列的进
- 另外一个栈2,用来实现队列的出
- 队列的进操作与判空只与栈1有关
- 而只要涉及到出队,返回队头元素操作,则将栈1全部弹出塞到栈2中,再对栈2进行对应的操作。执行完之后再全部弹回栈1
代码如下:
class MyQueue {private Deque<Integer> addDeque;private Deque<Integer> resultDeque;public MyQueue() {addDeque = new ArrayDeque();resultDeque = new ArrayDeque();}public void push(int x) {addDeque.push(x);}public int pop() {while(!addDeque.isEmpty()) resultDeque.push(addDeque.pop());int result = resultDeque.pop();while(!resultDeque.isEmpty()) addDeque.push(resultDeque.pop());return result;}public int peek() {while(!addDeque.isEmpty()) resultDeque.push(addDeque.pop());int result = resultDeque.peek();while(!resultDeque.isEmpty()) addDeque.push(resultDeque.pop());return result;}public boolean empty() {return addDeque.isEmpty();}
}
用队列实现栈
题目链接:225. 用队列实现栈
解题思路;
- 使用两个队列1、2来实现栈
- 添加元素直接往队列1中添加就行
- 而返回栈顶元素以及弹出栈顶元素操作的核心就在于控制队列1中唯一剩余的元素就是栈顶元素,其他的元素全部按顺序塞到队列2中去,完成对应操作之后再还原到队列1中
代码如下:
class MyStack {private Deque<Integer> addDeque;private Deque<Integer> resultDeque;public MyStack() {addDeque = new ArrayDeque();resultDeque = new ArrayDeque();}public void push(int x) {addDeque.add(x);}public int pop() {int count = 0;int bar = addDeque.size();while(!addDeque.isEmpty() && count++ != bar - 1) resultDeque.add(addDeque.remove());int result = addDeque.remove();while(!resultDeque.isEmpty()) addDeque.add(resultDeque.remove());return result;}public int top() {int count = 0;int bar = addDeque.size();while(!addDeque.isEmpty() && count++ != bar - 1) resultDeque.add(addDeque.remove());int result = addDeque.element();resultDeque.add(addDeque.remove());while(!resultDeque.isEmpty()) addDeque.add(resultDeque.remove());return result;}public boolean empty() {return addDeque.isEmpty();}
}
有效的括号
题目链接:20. 有效的括号
解题思路:
- 遍历字符串
- 将所有的左符号加入到栈中
- 如果遇到右括号,则查看栈顶元素
- 如果对应则弹出来,不对应则直接返回false
- 遍历完之后如果栈中为空则返回true
- 如果栈中仍有元素,则返回false
代码如下:
class Solution {public boolean isValid(String s) {Deque<Character> box = new ArrayDeque<>();for(int i = 0;i < s.length();i++) {char temp = s.charAt(i);if(temp == '(' || temp == '{' || temp == '[') box.push(temp);else if (box.isEmpty()) return false;else if (temp == ')') if(box.peek() == '(') box.pop();else return false;else if (temp == '}') if(box.peek() == '{') box.pop();else return false;else if (temp == ']') if(box.peek() == '[') box.pop();else return false;}return box.isEmpty();}
}
删除字符串中的所有相邻重复项
题目链接:1047. 删除字符串中的所有相邻重复项
解题逻辑:
- 遍历字符串,并尝试将字符添加到栈中
- 在将字符加入到栈中之前,先判断此时的栈顶元素是否与添加元素相同
- 如果相同则将栈顶元素弹出,并且该元素不添加
- 如果不同则将该元素添加到栈中
- 最后使用双端队列的特性将队尾元素一一拿出拼接得到最终答案
class Solution {public String removeDuplicates(String s) {Deque<Character> box = new ArrayDeque<>();for(int i = 0;i < s.length();i++) {char temp = s.charAt(i);if(!box.isEmpty() && temp == box.peek()) box.pop();else box.push(temp);}StringBuilder result = new StringBuilder();while (!box.isEmpty()) {result.append(box.pollLast());}return result.toString();}
}
注意:在使用Deque的栈api时,进行的元素压栈操作,相当于双端队列中的队头添加元素
逆波兰表达式求值
题目链接:150. 逆波兰表达式求值
解题逻辑:
- 遍历字符串数组
- 遇到数字就压栈
- 遇到符号就把栈顶的两个元素弹出来进行运算,算完之后再把结果压栈
- 如此循环往复直到遍历完整个字符串数组
代码如下:
class Solution {public int evalRPN(String[] tokens) {Deque<String> box = new ArrayDeque<>();for(String s : tokens) {if(s.equals("+")) box.push(Integer.toString(Integer.parseInt(box.pop()) + Integer.parseInt(box.pop()))); else if (s.equals("-")) box.push(Integer.toString( - (Integer.parseInt(box.pop()) - Integer.parseInt(box.pop())))); else if (s.equals("*")) box.push(Integer.toString(Integer.parseInt(box.pop()) * Integer.parseInt(box.pop()))); else if (s.equals("/")) {int a = Integer.parseInt(box.pop());int b = Integer.parseInt(box.pop());box.push(Integer.toString( b / a )); }else box.push(s);}return Integer.parseInt(box.pop());}
}
滑动窗口最大值
题目链接:239. 滑动窗口最大值
解题思路:
这道题我最先开始想的是使用优先级队列,但是优先级队列无法维护动态窗口的增添元素(因为优先级队列的底层是堆,添加进去就已经排好序了)。为了解决本题,我们应该使用另一种数据结构 – 单调队列。
这两种队列的概念如下:
单调队列
- 单调队列是一种特殊的队列,队列中的元素保持单调递增或递减。它的特点是:
- 元素有序:队列中的元素始终保持单调性。
- 高效操作:插入和删除操作都能保持队列的单调性,时间复杂度为 O (1)。
- 滑动窗口问题:常用于解决滑动窗口的最大值或最小值问题。
优先级队列
- 优先级队列是一种抽象数据类型,元素按照优先级排序,优先级高的元素先出队。它的特点是:
- 元素无序:元素按照优先级排序,但不保证完全有序。
- 高效操作:插入和删除操作的时间复杂度为 O (log n)。
- 贪心算法:常用于解决最优化问题,如 Dijkstra 算法。
那么我们如何保持单调队列的单调呢?
其核心就在于两个操作,例如现在我们需要维护一个单调递减的队列:
- add操作:我们只需要在push一个新元素进去的时候比较队尾的元素是否比队尾新进的元素要小(或等于),如果小(或等于)的话就入队,如果大的话就剔除队尾元素,直到队尾元素比新进元素要小
- remove操作:如果要删除的元素是最大元素(也就是队头元素),那么就执行remove操作,否则不进行操作
解题逻辑如下:
- 初始化两个指针代表窗口的两侧
- 初始化一个单调队列
- 将k个元素加入到队列中
- 随着窗口移动,根据指针进行元素的添加与剔除(如果窗口移除的元素value等于单调队列的出口元素,那么队列弹出元素,否则不用任何操作),并且将每次队头元素存储到数组中
- 返回数组
代码如下:
class Solution {public int[] maxSlidingWindow(int[] nums, int k) {int[] result = new int[nums.length - k + 1];int left = 0;int right = k - 1;MonotonousQueue queue = new MonotonousQueue();for(int i = 0;i < k;i++) queue.add(nums[i]);int pointer = 0; while(right < nums.length) {result[pointer++] = queue.element();if(nums[left] == queue.element()) queue.remove();left++;right++;if(right < nums.length) queue.add(nums[right]);}return result;}class MonotonousQueue {Deque<Integer> myQueue = new ArrayDeque<>();public void add(Integer item){while(!myQueue.isEmpty() && myQueue.peekLast() < item) myQueue.pollLast(); myQueue.add(item);}public Integer remove(){return myQueue.remove();}public Integer element(){return myQueue.element();}}
}
前 K 个高频元素
题目链接:347. 前 K 个高频元素
解题思路:
- 使用hashmap进行计数
- 定义一个对象,用于存储元素以及次数
- 遍历hashmap创建对象放入到优先级队列中
- 从优先级队列中弹出k个元素形成数组所谓结果
代码如下:
class Solution {public int[] topKFrequent(int[] nums, int k) {PriorityQueue<CountHelper> pq = new PriorityQueue<>(Comparator.comparingInt(CountHelper::getCount).reversed());Map<Integer,Integer> countMap = new HashMap<>();for(int i = 0;i < nums.length;i++) countMap.put(nums[i],countMap.getOrDefault(nums[i],0) + 1);for (Map.Entry<Integer, Integer> entry : countMap.entrySet()) {Integer key = entry.getKey();Integer value = entry.getValue();pq.add(new CountHelper(key,value));}int[] result = new int[k];int pointer = 0;while(pointer < k) result[pointer++] = pq.remove().getNum();return result;}class CountHelper {private int num;private int count;public CountHelper(int n, int c){num = n;count = c;}public int getNum(){return num;}public int getCount(){return count;}public void setNum(int n){num = n;}public void setCount(int c){count = c;}}
}