写在前面,许多问题并不只有一种解答思路
简单
中等
困难
单调栈问题有2大特点:(1)涉及到元素的比较(从小到大?字典序小?)(2)保持原有的相对顺序
保持栈中元素从大到小排列:
for () {
int cur = //当前元素
while(!stack.isEmpty() && cur > stack.peek()) { // 当前元素大于栈顶元素
stack.pop(); // 出栈
}
stack.push(i); //入栈
}
基本结构:
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;
例题:
二叉树路径 / 深度:
遍历二叉树:
构造二叉树:
利用二叉树的性质:
两个特征:单调性(从队列头部到队列尾部保持递减的顺序)、双端操作
思路:
例题:
大字符串包含小字符串
// 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 ++;
}
}
技巧:
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:在数组中查找符合条件的元素的下标
题型2:在一个有范围的区间里搜索一个整数
题型3:复杂的二分查找问题(判别条件需要遍历数组)
参考链接:写对二分查找不能靠模板,需要理解加练习 (附练习题,持续更新)
数据结构与算法——深度优先搜索(DFS)
DFS + 回溯的核心思路是画图!依据回溯图写代码!
liweiwei1419回溯算法入门级详解 + 练习:46. 全排列
洪水问题的标准解法是设置 visited 数组,设置方向数组,抽取私有方法
private static final int[][] Dir = {{-1, 0}, {0, -1}, {1, 0}, {0, 1}};
private boolean[][] visited;
排列、组合、子集相关问题解题的步骤是:先画图,再编码。去思考可以剪枝的条件, 为什么有的时候用 used 数组,有的时候设置搜索起点 begin 变量,理解状态变量设计的想法
简单
中等
17. 电话号码的字母组合
22. 括号生成
79. 单词搜索
93. 复原IP地址
784. 字母大小写全排列
困难
数据结构与算法——动态规划(DP)
中等
困难
简单
中等
困难
数据结构与算法——分治算法
典型的分治思想
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种思路总结
在对问题求解时,总是做出在当前看来是最好的选择。也就是说,不从整体最优上加以考虑,他所做出的是在某种意义上的局部最优解。选择的贪心策略必须具备无后效性,即某个状态以前的过程不会影响以后的状态,只与当前状态有关。
前缀和算法是一种重要的预处理算法,能大大降低查询的时间复杂度。最简单的题目就是:给定n个数和m次询问,每次询问一段区间的和。
比如求和为k的连续子数组的个数:
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;
}
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 代表出现了奇数次
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;
}
利用二进制表示状态
解决的问题是如何在任意多的候选人中,选出票数超过一半的那个人。
- 候选人(cand_num)初始化为nums[0],票数count初始化为1。
- 当遇到与cand_num相同的数,则票数count = count + 1,否则票数count = count - 1。
- 当票数count为0时,更换候选人,并将票数count重置为1。
- 遍历完数组后,cand_num即为最终答案。
求只出现一次的元素:使用异或 ^。规律:A ^ A = 0,A ^ 0 = A。
- 出现偶数次,异或结果为0。
- 两个数异或,二进制位相同的位置置0,不同的置1。
每个元素只出现一次,用二进制表示:0未出现、1出现
统计二进制中1的个数:判断最后一位是否为1(n & 1)、右移(n >>> 1)
状态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] 持股
详细题解:股票问题系列通解(转载翻译)
快速排序:直接通过快排切分,找到第 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;
}
}