写在前面,许多问题并不只有一种解答思路
一. 数据结构
1. 栈
1.1 栈
-
简单
-
中等
-
困难
1.2 单调栈
单调栈问题有2大特点:(1)涉及到元素的比较(从小到大?字典序小?)(2)保持原有的相对顺序
保持栈中元素从大到小排列:
for () {
int cur = //当前元素
while(!stack.isEmpty() && cur > stack.peek()) { // 当前元素大于栈顶元素
stack.pop(); // 出栈
}
stack.push(i); //入栈
}
- 简单
- 中等
- 困难
2. 链表
基本结构:
public class ListNode {
public int val;
public ListNode next;
public ListNode() {
}
public ListNode(int x) {
val = x;
}
}
基本操作:
- 插入
temp = 待插入位置的前驱节点.next
待插入位置的前驱节点.next = 待插入指针
待插入指针.next = temp
- 删除
待删除位置的前驱节点.next = 待删除位置的前驱节点.next.next
基本技巧:
- 增加虚拟头节点
ListNode dummy = new ListNode(-1);
dummy.next = head;
//...
return dummy.next;
例题:
3. 二叉树
二叉树路径 / 深度:
遍历二叉树:
- 中等
- 困难
构造二叉树:
利用二叉树的性质:
4. 队列
4.1 优先队列/堆
4.2 双端队列/单调队列
两个特征:单调性(从队列头部到队列尾部保持递减的顺序)、双端操作
思路:
- 给定初始【队列】
- 从【队列】尾部插入元素时,提前取出【单调队列】中所有比该元素小的元素,等价于维护单调队列的单调性
- 从【队列】头部删除元素时,需要判断待删除元素和【单调队列】头部元素是否相同,如果相同,则一同删除;如果不同,则表明待删除元素不是当前队列最大值
例题:
- 中等
- 困难
- 239. 滑动窗口最大值——已做
5. HashSet/HashMap
- 简单
- 中等
- 困难
- 41. 缺失的第一个正数 —— 原地哈希
- 128. 最长连续序列
6. 并查集
- 中等
- 困难
二. 算法
1. 双指针
1.1 双指针
1.2 滑动窗口
大字符串包含小字符串
- 模板思路
// 1. 构造数组、set、map存放滑动窗口中出现的元素
int[] letter = new int[26];
Set<Character> set = new HashSet<>();
Map<Character, Integer> map = new HashMap<>();
// 2. 定义窗口左右边界:[left, right)
int left = 0;
int right = 0;
int need_len = 0; // 统计小串中的元素在大串中出现的个数
// 比如小串为"aabc" ,窗口中出现"abc",need_len = 3,窗口中出现"aabc",need_len = 4
// 3. 右移窗口
while (right < len) {
char r = right处的字符
if (r在小串出现了) {
窗口中r的个数 + 1
if (窗口中r的个数 <= 小串中r的个数) need_len ++;
}
right ++; // 右移窗口
// 出错debug位置
System.out.println("left = " + left +", right = " + right);
// 判断窗口是否需要收缩
while (窗口满足要求,即need_len = 小串的长度) {
//可能在这里输出结果
//...
char l = left处的字符
if (l在小串出现了) {
if (窗口中l的个数 <= 小串中l的个数) need_len --;
窗口中l的个数 - 1
}
left ++;
}
}
- 简单
- 中等
- 困难
1.3 快慢指针
2. 二分查找
技巧:
- 划分区间:左闭右闭区间
int mid = left + (right - left) / 2;
防止越界
模板1:在循环体内部【查找】元素
while (left <= right )
- 退出循环的时候
left
和right
不重合
public int search(int[] nums, int target) {
int left = 0;
int right = nums.length - 1;
while (left <= right ) {
int mid = left + (right - left) / 2;
if (nums[mid] == target) {
return mid;
}
else if (nums[mid] > target) {
right = mid - 1;
}
else {
left= mid + 1;
}
}
return -1;
}
模板2:在循环体内部【排除】元素,可以解决绝大部分问题,重点掌握!
while (left < right )
- 退出循环的时候
left
和right
重合
public int search(int[] nums, int target) {
int left = 0;
int right = nums.length - 1;
while (left < right ) {
int mid = left + (right - left) / 2;
if (nums[mid] == target) {
return mid;
}
else if (nums[mid] > target) {
right = mid;
}
else {
left= mid + 1;
}
}
return nums[left] == target ? left : -1;
}
题型1:在数组中查找符合条件的元素的下标
- 704. 二分查找 —— 基本模板
- 852. 山脉数组的峰顶索引 —— 山脉数组
- 1095. 山脉数组中查找目标值 —— 山脉数组
- 33. 搜索旋转排序数组 —— 旋转数组
- 81. 搜索旋转排序数组 II —— 旋转数组
- 153. 寻找旋转排序数组中的最小值 —— 旋转数组
- 154. 寻找旋转排序数组中的最小值 II —— 旋转数组
- 34. 在排序数组中查找元素的第一个和最后一个位置 —— 区间搜索
- 300. 最长递增子序列 —— 涨见识
- 658. 找到 K 个最接近的元素
- 4. 寻找两个正序数组的中位数
题型2:在一个有范围的区间里搜索一个整数
题型3:复杂的二分查找问题(判别条件需要遍历数组)
- 中等
- 困难
参考链接:写对二分查找不能靠模板,需要理解加练习 (附练习题,持续更新)
3. BFS
4. DFS + 回溯
DFS + 回溯的核心思路是画图!依据回溯图写代码!
liweiwei1419回溯算法入门级详解 + 练习:46. 全排列
4.1 洪水问题
洪水问题的标准解法是设置 visited 数组,设置方向数组,抽取私有方法
private static final int[][] Dir = {{-1, 0}, {0, -1}, {1, 0}, {0, 1}};
private boolean[][] visited;
- 洪水问题(从一点出发,将所有与该点相连的点改变状态):
- 733. 图像渲染 —— 洪水问题
- 79. 单词搜索 —— 洪水问题
- 130. 被围绕的区域 —— 洪水问题
- 200. 岛屿数量 —— 洪水问题
排列、组合、子集相关问题解题的步骤是:先画图,再编码。去思考可以剪枝的条件, 为什么有的时候用 used 数组,有的时候设置搜索起点 begin 变量,理解状态变量设计的想法
4.2 排列、组合、子集相关问题
- 排列、组合、子集相关问题:
- 77. 组合 —— 组合问题
- 39. 组合总和 —— 组合问题
- 40. 组合总和 II —— 组合问题
- 46. 全排列 —— 排列问题
- 47. 全排列 II —— 排列问题
- 60. 排列序列 —— 排列问题
- 784. 字母大小写全排列 —— 排列问题
- 78. 子集 —— 子集问题
- 90. 子集 II —— 子集问题
4.3 数字问题
- 数字问题(需要注意数字过大的问题,另外一种形式的回溯问题)
- 306. 累加数 —— 数字问题
- 842. 将数组拆分成斐波那契序列 —— 数字问题
4.4 游戏问题
4.5 一般类型问题
5. DP
5.1 背包问题
- 中等
- 322. 零钱兑换 —— 完全背包
- 416. 分割等和子集 —— 01背包
- 474. 一和零 —— 01背包
- 494. 目标和 —— 01背包
- 1049. 最后一块石头的重量 II —— 01背包
- 困难
- 691. 贴纸拼词 —— 完全背包
- 879. 盈利计划 —— 01背包
- 1125. 最小的必要团队 —— 01背包
5.2 网格二维DP
-
中等
- 62. 不同路径:从网格的左上角到右下角有多少条不同的路径
- 63. 不同路径 II:网格中有障碍物,从网格的左上角到右下角有多少条不同的路径
- 64. 最小路径和:从网格的左上角到右下角的路径的和最小
- 120. 三角形最小路径和:从三角形顶端到三角形底端的路径的和最小
- 1594. 矩阵的最大非负积:从网格的左上角到右下角的路径的积最大
-
困难
- LCP 13. 寻宝:从网格的起点到终点拿起宝藏的路径
- 980. 不同路径 III:从网格的起点到终点不能重复通过网格
5.3 子序列问题
- 简单
- 53. 最大子序和 —— 子序列问题
- 中等
- 152. 乘积最大子数组 —— 子序列问题
- 300. 最长上升子序列 —— LIS
- 376. 摆动序列 —— 子序列问题
- 困难
- 面试题 17.08. 马戏团人塔 —— 二维LIS
- 面试题 08.13. 堆箱子 —— 三维LIS
- 354. 俄罗斯套娃信封问题 —— 二维LIS
- 960. 删列造序 III —— 子序列问题
- 5644. 得到子序列的最少操作次数 —— 转化为最长上升子序列问题
5.4 一般类型问题
-
简单
-
中等
- 62. 不同路径
- 64. 最小路径和
- 96. 不同的二叉搜索树
- 139. 单词拆分
- 221. 最大正方形
- 279. 完全平方数 —— 数字DP
- 337. 打家劫舍 III —— 树形DP
- 343. 整数拆分 —— 数字DP
-
困难
6. 分治
- 简单
7. 排序
7.1 排序
7.2 归并排序
典型的分治思想
代码:
public static void main(String[] args) {
int nums[] = { 8, 4, 55, 7, 1, 3, 6, 2 };
int[] temp = new int[nums.length];//提前定义临时数组,避免递归中反复开辟内存空间
mergeSort(nums, 0, nums.length - 1, temp);
}
//分+合方法
public static void mergeSort(int[] nums, int left, int right,int[] temp) {
if(left < right) {
int mid = (left + right) / 2; //中间索引
//向左递归进行分解
mergeSort(nums, left, mid, temp);
//向右递归进行分解
mergeSort(nums, mid + 1, right, temp);
//合并
merge(nums, left, mid, right, temp);
}
}
//合并两个有序数组:nums[left, mid] nums[mid + 1, right]
public static void merge(int[] nums, int left, int mid, int right, int[] temp) {
int l = left; // 初始化i, 左边有序序列的初始索引
int r = mid + 1; //初始化j, 右边有序序列的初始索引
//左边序列:[left-mid],右边序列:[mid+1,right]
for(int k = left; k <= right; k ++) {
//左边数添加完
if(l > mid) {
temp[k] = nums[r ++];
}
//右边数添加完
else if(r > right) {
temp[k] = nums[l ++];
}
//左边数小,先添加左边数
else if(nums[l] <= nums[r]) {
temp[k] = nums[l ++];
}
//右边数小,先添加右边数
else {
temp[k] = nums[r ++];
/
//这里是计算逆序对的关键
//应该先添加左边小的数,再添加右边大的数。
//但右边数比左边数小,即右边数比左边[l,mid]范围内的数都小
/
}
}
//将temp[]拷贝到arr[],合并的数从left--right,right右边的位置不考虑
for(int tempi = left; tempi <= right; tempi ++) {
nums[tempi] = temp[tempi];
}
}
这类问题还有其他思路:从后往前插入排序、构造二叉排序树、树状数组、线段树等。
详细题解思路参考:5种思路总结
7.3 快速排序
7.4 拓扑排序
- 中等
7.5 桶排序/计数排序
- 困难
8. 贪心
在对问题求解时,总是做出在当前看来是最好的选择。也就是说,不从整体最优上加以考虑,他所做出的是在某种意义上的局部最优解。选择的贪心策略必须具备无后效性,即某个状态以前的过程不会影响以后的状态,只与当前状态有关。
- 简单
- 中等
- 55. 跳跃游戏:尽可能跳远
- 134. 加油站
- 475. 供暖器
- 649. Dota2 参议院:让最先出现的对面阵营的人禁止投票
- 659. 分割数组为连续子序列
- 738. 单调递增的数字:出现尽可能多的9
- 955. 删列造序 II
- 1247. 交换字符使得字符串相同
- 1353. 最多可以参加的会议数目
9. 前缀和
前缀和算法是一种重要的预处理算法,能大大降低查询的时间复杂度。最简单的题目就是:给定n个数和m次询问,每次询问一段区间的和。
比如求和为k的连续子数组的个数:
- 使用前缀和快速计算区间和(需要O(n)计算前缀和,再O(n*n)遍历区间和)
public int subarraySum(int[] nums, int k) {
int len = nums.length;
// 计算前缀和数组
int[] preSum = new int[len + 1];
preSum[0] = 0;
for (int i = 0; i < len; i++) {
preSum[i + 1] = preSum[i] + nums[i];
}
int count = 0;
for (int left = 0; left < len; left++) {
for (int right = left; right < len; right++) {
// 区间和 [left..right],注意下标偏移
if (preSum[right + 1] - preSum[left] == k) {
count++;
}
}
}
return count;
}
- 前缀和优化:哈希表(边遍历,边存储前缀和,O(n))
- 前缀和优化:数组(明确哈希表的键有哪些时,可以使用数组代替哈希表)
public int subarraySum(int[] nums, int k) {
// key:前缀和,value:key 对应的前缀和的个数
Map<Integer, Integer> preSumFreq = new HashMap<>();
// 对于下标为 0 的元素,前缀和为 0,个数为 1
preSumFreq.put(0, 1);
int preSum = 0;
int count = 0;
for (int num : nums) {
preSum += num;
// 先获得前缀和为 preSum - k 的个数,加到计数变量里
if (preSumFreq.containsKey(preSum - k)) {
count += preSumFreq.get(preSum - k);
}
// 然后维护 preSumFreq 的定义
preSumFreq.put(preSum, preSumFreq.getOrDefault(preSum, 0) + 1);
}
return count;
}
比如求包含的元音均为偶数个的连续子数组的最大长度:连续子数组的含有元音的个数只能是偶数或者奇数,可以使用二进制表示,0 代表出现了偶数次,1 代表出现了奇数次
- 前缀和进一步优化2:哈希表 + 状态压缩
- 前缀和进一步优化2:数组 + 状态压缩
public int findTheLongestSubstring(String s) {
int n = s.length();
int[] pos = new int[1 << 5];
Arrays.fill(pos, -1);
int ans = 0, status = 0;
pos[0] = 0;
for (int i = 0; i < n; i++) {
char ch = s.charAt(i);
if (ch == 'a') {
status ^= (1 << 0);
} else if (ch == 'e') {
status ^= (1 << 1);
} else if (ch == 'i') {
status ^= (1 << 2);
} else if (ch == 'o') {
status ^= (1 << 3);
} else if (ch == 'u') {
status ^= (1 << 4);
}
if (pos[status] >= 0) {
ans = Math.max(ans, i + 1 - pos[status]);
} else {
pos[status] = i + 1;
}
}
return ans;
}
10. 状态压缩
利用二进制表示状态
- 简单
- 中等
- 464. 我能赢吗 DP + 状压
- 1371. 每个元音包含偶数次的最长子字符串 —— 前缀和 + 状压
- 困难
- 51. N 皇后 —— 回溯 + 状压
- 1349. 参加考试的最大学生数 —— DP + 状压
- 1542. 找出最长的超赞子字符串 —— 前缀和 + 状压
11. 摩尔投票法
解决的问题是如何在任意多的候选人中,选出票数超过一半的那个人。
- 候选人(cand_num)初始化为nums[0],票数count初始化为1。
- 当遇到与cand_num相同的数,则票数count = count + 1,否则票数count = count - 1。
- 当票数count为0时,更换候选人,并将票数count重置为1。
- 遍历完数组后,cand_num即为最终答案。
- 简单
- 中等
三. 常见类型题
1. 区间问题
2. 最大子序和问题
- 简单
- 困难
3. 岛屿问题(网格DFS+BFS)
- 简单
- 中等
- 困难
4. 位运算
求只出现一次的元素:使用异或 ^。规律:A ^ A = 0,A ^ 0 = A。
- 出现偶数次,异或结果为0。
- 两个数异或,二进制位相同的位置置0,不同的置1。
每个元素只出现一次,用二进制表示:0未出现、1出现
- 中等
统计二进制中1的个数:判断最后一位是否为1(n & 1)、右移(n >>> 1)
- 中等
5. 设计类问题
- 简单
- 中等
- 困难
6. 回文数/串
7. 数字操作
- 简单
- 中等
- 困难
8. 股票问题(增加dp维数消除后效性)
状态1:第i天不持股
状态转移:第i - 1天不持股 or 第i - 1天持股 + 第i天卖出股票
状态2:第i天持股
状态转移:第i - 1天持股 or 第i - 1天持股 + 第i天买入股票
=====
所以第i天的状态与第i - 1天状态有关,违反了后效性,所以需要增加dp维数
dp[i][0] 不持股
dp[i][1] 持股
详细题解:股票问题系列通解(转载翻译)
- 简单
- 中等
- 困难
9. 打家劫舍问题(增加dp维数消除后效性)
- 简单
- 中等
- 213. 打家劫舍 II
- 337. 打家劫舍 III —— 树形DP
10. 数学推理
11. 操作矩阵
12. n数之和
13. 最大矩形
14. TOPK问题
快速排序:直接通过快排切分,找到第 K 小的数(即下标为 k - 1) 【O(N)】
基本思想是:通过一趟排序将要排序的数据分割成独立的两部分,其中一部分的所有数据都比另外一部分的所有数据都要小
class Solution {
public int[] getLeastNumbers(int[] arr, int k) {
if (k == 0 || arr.length == 0) {
return new int[0];
}
// 最后一个参数表示我们要找的是下标为k-1的数
return quickSearch(arr, 0, arr.length - 1, k - 1);
}
private int[] quickSearch(int[] nums, int left, int right, int k) {
// 每快排切分1次,找到排序后下标为index的元素,如果index恰好等于k就返回index以及index左边所有的数;
int index = partition(nums, left, right);
if (index == k) {
return Arrays.copyOf(nums, index + 1);
}
// 否则根据下标j与k的大小关系来决定继续切分左段还是右段。
return index > k? quickSearch(nums, left, index - 1, k): quickSearch(nums, index + 1, right, k);
}
// 快排切分,返回下标index,使得比nums[index]小的数都在index的左边,比nums[index]大的数都在index的右边。
private int partition(int[] nums, int left, int right) {
int temp = 0;
//基准数据
int pivot = nums[left];
//划分区间:基准左面的数小,基准右面的数大
while (left < right) {
//如果队尾的元素大于基准,左移
while (nums[right] >= pivot && left < right) {
right--;
}
//如果队尾元素小于pivot了,交换
if (nums[right] < pivot) {
temp = nums[left];
nums[left] = nums[right];
nums[right] = temp;
}
//如果队首的元素小于基准,右移
while (nums[left] <= pivot && left < right) {
left++;
}
//如果队首元素大于pivot时,交换
if(nums[left] > pivot) {
temp = nums[left];
nums[left] = nums[right];
nums[right] = temp;
}
}
//跳出循环时left和right相等,此时的left或right就是pivot的正确索引位置
return left;
}
}
大根堆:自带PriorityQueue 【O(NlogK)】
// 保持堆的大小为K,然后遍历数组中的数字,遍历的时候做如下判断:
// 1. 若目前堆的大小小于K,将当前数字放入堆中。
// 2. 否则判断当前数字与大根堆堆顶元素的大小关系,如果当前数字比大根堆堆顶还大,这个数就直接跳过;
// 反之如果当前数字比大根堆堆顶小,先poll掉堆顶,再将该数字放入堆中。
class Solution {
public int[] getLeastNumbers(int[] arr, int k) {
if (k == 0 || arr.length == 0) {
return new int[0];
}
// 默认是小根堆,实现大根堆需要重写一下比较器。
Queue<Integer> pq = new PriorityQueue<>((v1, v2) -> v2 - v1);
for (int num: arr) {
if (pq.size() < k) {
pq.offer(num);
} else if (num < pq.peek()) {
pq.poll();
pq.offer(num);
}
}
// 返回堆中的元素
int[] res = new int[pq.size()];
int idx = 0;
for(int num: pq) {
res[idx++] = num;
}
return res;
}
}
二叉搜索树TreeMap,求得的前K大的数字是有序的 【O(NlogK)】
class Solution {
public int[] getLeastNumbers(int[] arr, int k) {
if (k == 0 || arr.length == 0) {
return new int[0];
}
// TreeMap的key是数字, value是该数字的个数。
// cnt表示当前map总共存了多少个数字。
TreeMap<Integer, Integer> map = new TreeMap<>();
int cnt = 0;
for (int num: arr) {
// 1. 遍历数组,若当前map中的数字个数小于k,则map中当前数字对应个数+1
if (cnt < k) {
map.put(num, map.getOrDefault(num, 0) + 1);
cnt++;
continue;
}
// 2. 否则,取出map中最大的Key(即最大的数字), 判断当前数字与map中最大数字的大小关系:
// 若当前数字比map中最大的数字还大,就直接忽略;
// 若当前数字比map中最大的数字小,则将当前数字加入map中,并将map中的最大数字的个数-1。
Map.Entry<Integer, Integer> entry = map.lastEntry();
if (entry.getKey() > num) {
map.put(num, map.getOrDefault(num, 0) + 1);
if (entry.getValue() == 1) {
map.pollLastEntry();
} else {
map.put(entry.getKey(), entry.getValue() - 1);
}
}
}
// 最后返回map中的元素
int[] res = new int[k];
int idx = 0;
for (Map.Entry<Integer, Integer> entry: map.entrySet()) {
int freq = entry.getValue();
while (freq-- > 0) {
res[idx++] = entry.getKey();
}
}
return res;
}
}
数据范围有限,直接计数排序 【O(N)】
基本思想:开辟一个额外空间(数组),统计元素个数,然后遍历这个数组,依此添加个数大于0的元素
详情参考:4种解法秒杀TopK(快排/堆/二叉搜索树/计数排序)❤️
class Solution {
public int[] getLeastNumbers(int[] arr, int k) {
if (k == 0 || arr.length == 0) {
return new int[0];
}
// 统计每个数字出现的次数
int[] counter = new int[10001];
for (int num: arr) {
counter[num]++;
}
// 根据counter数组从头找出k个数作为返回结果
int[] res = new int[k];
int idx = 0;
for (int num = 0; num < counter.length; num++) {
while (counter[num]-- > 0 && idx < k) {
res[idx++] = num;
}
if (idx == k) {
break;
}
}
return res;
}
}