PS:这是我在刷leetcode的时候做的一些笔记,提到了很多框架,套路,这些是为了解题,但是算法真正吸引人的,是逻辑思考的过程。就像登山的目的并不只是为了登上山顶,拍照打卡。
计算机解决问题其实没有任何特殊的技巧,它唯一的解决办法就是穷举,穷举所有可能性。算法设计无非就是先思考“如何穷举”,然后再追求“如何聪明地穷举”。
1. 链表
some tips:
- 当你需要创造一条新链表的时候,可以使用虚拟头结点简化边界情况的处理。
- 如果我们需要把原链表的节点接到新链表上,而不是 new 新节点来组成新链表的话,那么最好断开节点和原链表之间的链接
- 在处理数组和链表相关问题时,双指针技巧是经常用到的,双指针技巧主要分为两类:左右指针和快慢指针
1.1. 典型例题
1.1.1. 删除重复链表(我觉得这个比较有套路,思想很简单,但是调试很麻烦)
class Solution {
public ListNode deleteDuplicates(ListNode head) {
ListNode dump = new ListNode(0);
dump.next = head;
ListNode p = dump;
ListNode cur =head;
while (cur != null) {
if (cur.next != null && cur.val == cur.next.val) {
while(cur.next != null && cur.val == cur.next.val){
cur=cur.next;
}
cur = cur.next;
if(cur==null){
p.next=null;
}
} else {
p.next = cur;
cur = cur.next;
p = p.next;
}
}
return dump.next;
}
}
1.1.2. 两两交换链表中的节点
这应该是 leetcode 中唯一一道需要用递归来解决的链表题目吧(用迭代很麻烦)
class Solution {
public ListNode swapPairs(ListNode head) {
if(head==null||head.next==null){
return head;
}
ListNode newHead = head.next;
head.next=swapPairs(newHead.next);
newHead.next=head;
return newHead;
}
}
1.1.3. 反转链表
ListNode revsese(ListNode head){
ListNode cur = head,pre = null ;
while(cur!=null){
ListNode next = cur.next;
//这里的pre其实趋向于后面,但是
cur.next =pre;
pre=cur;
cur=next;
}
return pre;
}
2. 数组
主要技巧: 左右指针和快慢指针
2.1. 滑动窗口算法框架
// 滑动窗口算法框架伪码
int left = 0, right = 0;
while (right < nums.size()) {
// 增大窗口
window.addLast(nums[right]);
right++;
while (window needs shrink) {
// 缩小窗口
window.removeFirst(nums[left]);
left++;
}
}
2.2. 典型例题(技巧性强的题)
2.2.1. 何为 K 的子数组
这个题目需要用到前缀和的技巧
class Solution {
public int subarraySum(int[] nums, int k) {
// 啦、
int n = nums.length;
int[] preNum = new int[n+1];
Map<Integer,Integer> map = new HashMap<>();
int res = 0;
map.put(0,1);
for(int i=0;i<n;i++){
preNum[i+1] = nums[i]+preNum[i];
int need = preNum[i+1]-k;
if (map.containsKey(need)){
res+= map.get(need);
}
map.put(preNum[i+1],map.getOrDefault(preNum[i+1],0)+1);
}
return res;
}
}
2.2.2. 多数元素
技巧就是正负电荷抵消
class Solution {
public int majorityElement(int[] nums) {
// 我们想寻找的那个众数
int target = 0;
// 计数器(类比带电粒子例子中的带电性)
int count = 0;
for (int i = 0; i < nums.length; i++) {
if (count == 0) {
// 当计数器为 0 时,假设 nums[i] 就是众数
target = nums[i];
// 众数出现了一次
count = 1;
} else if (nums[i] == target) {
// 如果遇到的是目标众数,计数器累加
count++;
} else {
// 如果遇到的不是目标众数,计数器递减
count--;
}
}
// 回想带电粒子的例子
// 此时的 count 必然大于 0,此时的 target 必然就是目标众数
return target;
}
}
2.2.3. 移动零
给定一个数组 nums
,编写一个函数将所有 0
移动到数组的末尾,同时保持非零元素的相对顺序
这个题目初看很有迷惑性,我想到的是,前后指针,然后每次找到 0 之后把数据都移动一遍,但是其实和删除重复数组一样的解法,比较像直接前后指针
3. BFS
//这里是按照图的理解
// 计算从起点 start 到终点 target 的最近距离
int BFS(Node start, Node target) {
// 核心数据结构
Queue<Node> q;
// 避免走回头路
Set<Node> visited;
// 将起点加入队列
q.offer(start);
visited.add(start);
while (q not empty) {
int sz = q.size();
// 将当前队列中的所有节点向四周扩散
for (int i = 0; i < sz; i++) {
Node cur = q.poll();
// 划重点:这里判断是否到达终点
if (cur is target)
return step;
// 将 cur 的相邻节点加入队列
for (Node x : cur.adj()) {
if (x not in visited) {
q.offer(x);
visited.add(x);
}
}
}
}
// 如果走到这里,说明在图中没有找到目标节点
}
4. 动态规划
首先,动态规划的核心思想就是穷举求最值,只有列出正确的「状态转移方程」,才能正确地穷举。而且,你需要判断算法问题是否具备「最优子结构」,是否能够通过子问题的最值得到原问题的最值。另外,动态规划问题存在「重叠子问题」,如果暴力穷举的话效率会很低,所以需要你使用「备忘录」或者「DP table」来优化穷举过程,避免不必要的计算。
明确「状态」-> 明确「选择」 -> 定义 dp
数组/函数的含义
状态转移方程可以用归纳法
想满足最优子结,子问题之间必须互相独立
但反过来,最优子结构性质作为动态规划问题的必要条件,一定是让你求最值的, 以后碰到那种恶心人的最值题,思路往动态规划想就对了,这就是套路。
动态规划不就是从最简单的 base case 往后推导吗,可以想象成一个链式反应,从小变大。但只有符合最优子结构的问题,才有发生这种链式反应的性质。
找最优子结构的过程,其实就是证明状态转移方程正确性的过程,方程符合最优子结构就可以写暴力解了,写出暴力解就可以看出有没有重叠子问题了,有则优化,无则 OK。这也是套路,经常刷题的读者应该能体会。
dp 数组要设置为 dp【n+1】,目的是处理 base case
1、遍历的过程中,所需的状态必须是已经计算出来的。
2、遍历结束后,存储结果的那个位置必须已经被计算出来。
4.1.1. 股票问题
状态(变量)
选择(变量变化)
状态转移方程
base case:
dp[-1][...][0] = dp[...][0][0] = 0
dp[-1][...][1] = dp[...][0][1] = -infinity
状态转移方程:
dp[i][k][0] = max(dp[i-1][k][0], dp[i-1][k][1] + prices[i])
dp[i][k][1] = max(dp[i-1][k][1], dp[i-1][k-1][0] - prices[i])
实际代码最基础的版本,k是无穷
class Solution {
public int maxProfit(int[] prices) {
int dp_i_0 = 0,dp_i_1 = -prices[0];
for(int i=1;i<prices.length;i++){
int temp=dp_i_0;
dp_i_0=Math.max(dp_i_0,dp_i_1+prices[i]);
dp_i_1=Math.max(dp_i_1,temp-prices[i]);
}
return dp_i_0;
}
}
5. 二分搜索
5.1. 框架
第一个,最基本的二分查找算法:
int binary_search(int[] nums, int target) {
int left = 0, right = nums.length - 1;
while(left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] < target) {
left = mid + 1;
} else if (nums[mid] > target) {
right = mid - 1;
} else if(nums[mid] == target) {
// 直接返回
return mid;
}
}
// 直接返回
return -1;
}
第二个,寻找左侧边界的二分查找:
int left_bound(int[] nums, int target) {
int left = 0, right = nums.length - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] < target) {
left = mid + 1;
} else if (nums[mid] > target) {
right = mid - 1;
} else if (nums[mid] == target) {
// 别返回,锁定左侧边界
right = mid - 1;
}
}
// 判断 target 是否存在于 nums 中
if (left < 0 || left >= nums.length) {
return -1;
}
// 判断一下 nums[left] 是不是 target
return nums[left] == target ? left : -1;
}
第三个,寻找右侧边界的二分查找:
int right_bound(int[] nums, int target) {
int left = 0, right = nums.length - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] < target) {
left = mid + 1;
} else if (nums[mid] > target) {
right = mid - 1;
} else if (nums[mid] == target) {
// 别返回,锁定右侧边界
left = mid + 1;
}
}
// 判断 target 是否存在于 nums 中
// if (left - 1 < 0 || left - 1 >= nums.length) {
// return -1;
// }
// 由于 while 的结束条件是 right == left - 1,且现在在求右边界
// 所以用 right 替代 left - 1 更好记
if (right < 0 || right >= nums.length) {
return -1;
}
return nums[right] == target ? right : -1;
}
5.2. 典型题目
5.2.1. 找峰值
class Solution {
public int findPeakElement(int[] nums) {
int l =0,r= nums.length-1;
while(l<r){
int mid = (r-l)/2+l;
//这里有意思的点是,如果变成if(nums[mid-1]>nums[mid]),
//从逻辑上是对的,但是会出现递增情况死循环的问题
//仔细想了一下,是因为l是0开始的,如果下面的代码出现递减情况,
// r=mid,可以保证一直往左逼进,任何数一直除以2,都会变成0的
//但是如果是if(nums[mid-1]>nums[mid])
// l=2,r=3,mid一直是2。会出现(n+(n+1))/2 等于n的循环。
if(nums[mid]<nums[mid+1]){
l=mid+1;
}else{
r=mid;
}
}
return l;
}
}
6. 回溯/DFS
6.1. 回溯和 DFS 区别?
其实不就是遍历吗?它俩的本质是一样的,都是「遍历」思维下的暴力穷举算法。唯一的区别在于关注点不同,回溯算法的关注点在「树枝」,DFS 算法的关注点在「节点」。
result = []
def backtrack(路径, 选择列表):
if 满足结束条件:
result.add(路径)
return
for 选择 in 选择列表:
做选择
backtrack(路径, 选择列表)
撤销选择
// 另一种理解:这两种其实没多大区别
// 回溯算法框架模板
void backtrack(...) {
if (到达叶子节点) {
return;
}
for (int i = 0; i < n; i++;) {
// 做选择
...
backtrack(...)
// 撤销选择
...
}
}
// DFS 算法框架模板
void dfs(...) {
if (到达叶子节点) {
return;
}
// 做选择
...
for (int i = 0; i < n; i++) {
dfs(...)
}
// 撤销选择
...
}
6.2. 回溯之排列/组合/子集问题
其实这写问题本质就是穷举所有解,而这些解呈现树形结构,所以合理使用回溯算法框架,稍改代码框架即可把这些问题一网打尽。首先,组合问题和子集问题其实是等价的,这个后面会讲;至于之前说的三种变化形式,无非是在这两棵树上剪掉或者增加一些树枝罢了。
这里其实就是多叉树遍历的视角,但是要涉及到减枝
6.2.1. 代码框架
class Solution {
List<List<Integer>> res = new LinkedList<>();
LinkedList<Integer> track = new LinkedList<>();
public List<List<Integer>> subsets(int[] nums) {
backtrack(nums, 0);
return res;
}
//求子集(元素无重复不可重复选)
void backtrack(int[] nums, int start) {
res.add(new LinkedList<>(track));
for (int i = start; i < nums.length; i++) {
track.addLast(nums[i]);
backtrack(nums, i + 1);
track.removeLast();
}
}
//求组合问题(元素无重复不可重复选)
void backtrack(int[] nums, int start) {
// 回看之前的组合树,就是树的第nums.length层
if(track.size()==nums.length){
res.add(new LinkedList<>(track));
}
for (int i = start; i < nums.length; i++) {
track.addLast(nums[i]);
backtrack(nums, i + 1);
track.removeLast();
}
}
//求排列问题(元素无重复不可重复选),排列相比组合是有顺序的,
//每次 for循环从0开始
// 同时加一个字段判断num[i]有没有被使用过
static boolen[] used = new boolean[nums.length];
void backtrack(int[] nums) {
if(track.size()==nums.length){
res.add(new LinkedList<>(track));
}
for (int i = 0; i < nums.length; i++) {
if(!used[i]){
continue;
}
track.addLast(nums[i]);
used[i]!=used[i];
backtrack(nums);
used[i]!=used[i];
track.removeLast();
}
}
//求子集(元素重复不可重复选)
//比如 [ [],[1],[2],[1,2],[2,2],[1,2,2] ]
// 加个去重逻辑就可以了呗
// 1.先排序,然后if (i > start && nums[i] == nums[i - 1]) {continue;}
// 2.直接用set存储去重结果
//针对(元素重复不可重复选)这种问题组合和排列都是一样的解法
void backtrack(int[] nums, int start) {
res.add(new LinkedList<>(track));
for (int i = start; i < nums.length; i++) {
if (i > start && nums[i] == nums[i - 1]) {
continue;
}
track.addLast(nums[i]);
backtrack(nums, i + 1);
track.removeLast();
}
}
//求子集(元素无重复可重复选)
//这种问题核心就是如何让元素重复选择?
//把backtrack(nums, i);该为backtrack(nums, i+1);就可以了
//为了防止树的不断增长那个,肯定是有相对应的停止条件的
}
7. 二叉树
二叉树题目的解法可以分两类思路,第一类是遍历一遍二叉树得出答案,第二类是通过分解问题计算出答案,
7.1. 遍历
前中后序遍历
前序位置的代码在刚刚进入一个二叉树节点的时候执行;
后序位置的代码在将要离开一个二叉树节点的时候执行;
中序位置的代码在一个二叉树节点左子树都遍历完,即将开始遍历右子树的时候执行。
你可以发现,前序位置的代码执行是自顶向下的,而后序位置的代码执行是自底向上的
但这里面大有玄妙,意味着前序位置的代码只能从函数参数中获取父节点传递来的数据,而后序位置的代码不仅可以获取参数数据,还可以获取到子树通过函数返回值传递回来的数据。
遇到子树问题,首先想到的是给函数设置返回值,然后在后序位置做文章。
void traverse(TreeNode root) {
if (root == null) {
return;
}
// 前序位置
traverse(root.left);
// 中序位置
traverse(root.right);
// 后序位置
}
7.2. 分解
这里思路就是递归了
整个递归函数看做是在这个节点里面发生的事情,如何跳出,然后递归主要逻辑,最后 return 什么。(递归本身就很)
🙋♀️🌰:递归些前序遍历:
序遍历的特点是,根节点的值排在首位,接着是左子树的前序遍历结果,最后是右子树的前序遍历结果:那这不就可以分解问题了么,一棵二叉树的前序遍历结果 = 根节点 + 左子树的前序遍历结果 + 右子树的前序遍历结果。
class Solution {
// 定义:输入一棵二叉树的根节点,返回这棵树的前序遍历结果
List<Integer> preorderTraversal(TreeNode root) {
List<Integer> res = new LinkedList<>();
if (root == null) {
return res;
}
// 前序遍历的结果,root.val 在第一个
res.add(root.val);
// 利用函数定义,后面接着左子树的前序遍历结果
res.addAll(preorderTraversal(root.left));
// 利用函数定义,最后接着右子树的前序遍历结果
res.addAll(preorderTraversal(root.right));
return res;
}
}
中序和后序遍历也是类似的,只要把 add(root.val)
放到中序和后序对应的位置就行了。
7.3. 通用解题步骤
遇到一道二叉树的题目时的通用思考过程是:
1、是否可以通过遍历一遍二叉树得到答案?如果可以,用一个 traverse
函数配合外部变量来实现。
2、是否可以定义一个递归函数,通过子问题(子树)的答案推导出原问题的答案?如果可以,写出这个递归函数的定义,并充分利用这个函数的返回值。
3、无论使用哪一种思维模式,你都要明白二叉树的每一个节点需要做什么,需要在什么时候(前中后序)做。只有后序位置才能通过返回值获取子树的信息。
7.4. 思考
动归/DFS/回溯算法都可以看做二叉树问题的扩展,只是它们的关注点不同:
- 动态规划算法属于分解问题(分治)的思路,它的关注点在整棵「子树」。
- 回溯算法属于遍历的思路,它的关注点在节点间的「树枝」。
- DFS 算法属于遍历的思路,它的关注点在单个「节点」。
7.5. 常用细节
//一般判断叶子节点这样判断
if (root.left == null && root.right == null) {
// 到达叶子节点,更新最大深度
res = Math.max(res, depth);
}
8. 常用 api
hashmap
h.containsKey(k)//Key要大写
h.putIfAbsent(k,v)
h.size()
h.getOrDefault(c,0)
h.remove;
LinkedHashSet
// 获取第一个
lhs.iterator().next()
Lsit
l.size();
res.add(new ArrayList<>(track));//多重
res.remove(track.size()-1);
res.add(String.join(".",track));
Integer.parseInt
Deque
// 用作栈
pop(),peek(),push()
// 用做队列
offer()(作用与尾部),poll()(头部),remove()(头部)
Arrays
Arrays.fill(num,value);
Arrays.sort(a);
Arrays.asList();
返回数组
return new ListNode[]{tail,head};
return new int[0];
基础
boolean[] used = new boolean[nums.length];
优先队列
// 表示最小堆
PriorityQueue<ListNode> pq = new PriorityQueue<>(lists.length, (a, b)->(a.val - b.val));
lists.length需要检查不能为0
!pq.isEmpty()
pq.add()
pq.peek()
pq.poll()//这个是弹出
String
s.substring(s,i,j);//左闭右开
StringBuilder str = new StringBuilder();
return s.substring(i+1,j);
Integer
Integer.parseInt()
int ran = new Random.nextInt(rigth-left+1)+left;
while(l<r&&nums[l+1]==nums[l]) l++;
while(l<r&&nums[r-1]==nums[r]) r--;
9. 常用小技巧
10. 注意事项
要给出具体字符串要用回溯,但是最后给一个数字是dp
看有没有可能写错大小写
有时候报错要分析上下文,冷静,在某一点报错可能是别的地方的原因
可以慢一点,但是不能出错
复制的地方记得改回来
不要先入为主的思路去看代码
固定一下函数命令习惯:backtrack/dfs/traverse
11. 附加题
11.1. 抽牌问题
- 你手中有一个初始的牌堆
hand
,并且有一个辅助的空牌堆desk
。 - 你将按以下规则操作牌:
- 从
hand
的顶部取出一张牌,放到desk
。 - 如果
hand
中还有牌,再从hand
的顶部取出一张牌,然后将这张牌放回hand
的底部。 - 重复以上步骤,直到
hand
中的所有牌都移动到desk
。 - 把场景抽象成一个具体的结构
//左侧是队头,右侧是队尾
」 while (!hand.isEmpty()) {
desk.add(hand.poll();
if (!hand.isEmpty()) {
hand.add(hand.poll());
}
}
但是 deque 不支持 add(0,e)这种,支持 addLast
while (!desk.isEmpty()) {
hand.add(desk.remove());
if (!hand.isEmpty()) {
hand.add(hand.remove());
}
}