void traverse(int[] arr) {
for (int i = 0; i < arr.length; i++) {
// 迭代访问 arr[i]
}
}
// 下面,我们将 LeetCode 中的给出的链表的节点这个类进行一些扩展,方便我们的调试
// 1、给出一个数字数组,通过数组构建数字链表
public ListNode(int[] arr) {
if(arr == null || arr.length == 0){
throw new IllegalArgumentException("arr can not be empty");
}
// 体会这里 this 指代了什么,其实就是 head
// 因为这是一个构造函数,所以也无须将 head 返回
this.val = arr[0];
ListNode cur = this;
for (int i = 1; i < arr.length; i++) {
cur.next = new ListNode(arr[i]);
cur = cur.next;
}
}
// 2、重写 toString() 方法,方便我们查看链表中的元素
@Override
public String toString() {
StringBuilder s = new StringBuilder();
ListNode cur = this; // 还是要特别注意的是,理解这里 this 的用法
while (cur!=null){
s.append(cur.val + "->");
cur = cur.next;
}
s.append("NULL");
return s.toString();
}
https://leetcode-cn.com/problems/merge-two-sorted-lists/solution/xin-shou-you-hao-xue-hui-tao-lu-bu-fan-cuo-4nian-l/
https://liweiwei1419.gitee.io/leetcode-algo/leetcode-by-tag/linked-list/
因为涉及第 1 个结点的操作,为了避免分类讨论,常见的做法是引入虚拟头结点
比较直观的思路是先断开链表,逆转,拼回去
https://leetcode-cn.com/problems/merge-k-sorted-lists/solution/4-chong-fang-fa-xiang-jie-bi-xu-miao-dong-by-sweet/
1.K 指针:K 个指针分别指向 K 条链表
2.使用小根堆对 1 进行优化
3.逐一合并两条链表
4.两两合并对 3 进行优化
需要操作第一个节点的时候,为了避免讨论,设置哑节点
用两个链表,一个链表放小于x的节点,一个链表放大于等于x的节点
最后,拼接这两个链表.
用栈解决逆序处理
头插法保证结果逆序
使用了双端队列:Deque,可以在两端操作数据:主要有:ArrayDeque,LinkedList
/* 基本的单链表节点 */
class ListNode {
int val;
ListNode next;
}
void traverse(ListNode head) {
for (ListNode p = head; p != null; p = p.next) {
// 迭代访问 p.val
}
}
void traverse(ListNode head) {
// 递归访问 head.val
traverse(head.next)
}
/* 基本的二叉树节点 */
class TreeNode {
int val;
TreeNode left, right;
}
void traverse(TreeNode root) {
// 前序遍历
traverse(root.left)
// 中序遍历
traverse(root.right)
// 后序遍历
}
/* 基本的 N 叉树节点 */
class TreeNode {
int val;
TreeNode[] children;
}
void traverse(TreeNode root) {
for (TreeNode child : root.children)
traverse(child)
}
https://labuladong.gitbook.io/algo/di-ling-zhang-bi-du-xi-lie/shuang-zhi-zhen-ji-qiao
一类是「快慢指针」,一类是「左右指针」。前者解决主要解决链表中的问题,比如典型的判定链表中是否包含环;后者主要解决数组(或者字符串)中的问题,比如二分查找。
快慢指针一般都初始化指向链表的头结点 head,前进时快指针 fast 在前,慢指针 slow 在后,巧妙解决一些链表中的问题。
ListNode fast, slow;
fast = slow = head;
fast = fast.next.next;
slow = slow.next;
常见问题:
用快慢指针解决循环问题,指针相遇就是有循环
左右指针在数组中实际是指两个索引值,一般初始化为 left = 0, right = nums.length - 1
常见问题:
查找问题,并且题目要求算法时间复杂度必须是 O(log n) 级别
一般都是用二分查找
「二分查找」不是只能应用在有序数组里,只要是可以使用「减治思想」的问题,都可以使用二分查找
https://ojeveryday.github.io/AlgoWiki/#/BinarySearch/03-template-2
https://leetcode-cn.com/problems/split-array-largest-sum/solution/pao-ding-jie-niu-dai-ni-yi-bu-bu-shi-yong-er-fen-c/
难点:
1.找出要查找的变量是什么?即我们的left,right 和 mid 表示的是什么?也即我们查找的范围是什么?
2.哪些是这些变量的更新准则?如何缩小范围?
public int search(int[] nums, int left, int right, int target) {
while (left < right) {
// 选择中位数时下取整
// 需要根据搜索区间只有两个元素时的情况,决定向上取整还是向下取整
int mid = left + (right - left) / 2;
if (check(mid)) {
// 只考虑什么时候不是解
// 下一轮搜索区间是 [mid + 1, right]
left = mid + 1
} else {
// 不需要考虑为什么是这个区间,只需要简单的取上一个区间的补集
// 下一轮搜索区间是 [left, mid]
// 由于设置的是right=mid,所以用向下取整,否则当搜索区间只有两个数的时候mid=right,陷入死循环
right = mid
}
}
// 退出循环的时候,程序只剩下一个元素没有看到。
// 视情况,是否需要单独判断 left(或者 right)这个下标的元素是否符合题意
}
利用减治思想可以用一次二分查找就得到结果
比较复杂的问题,特别是有序性不好,分隔区间有重叠的问题,最好多用几次二分查找求解,而不是一次性求解
这个问题,一次二分查找,分两次二分查找都可以做出来,还是比较推荐分两次二分查找,写起来比较直观,不容易出错
抽屉原理:桌上有十个苹果,要把这十个苹果放到九个抽屉里,无论怎样放,我们会发现至少会有一个抽屉里面放不少于两个苹果
这道题要求我们查找的数是一个整数,并且给出了这个整数的范围(在 11 和 nn 之间,包括 1 和 n),并且给出了一些限制,于是可以使用二分查找法定位在一个区间里的整数,利用抽屉原理判断哪边区间存在重复数
由题意可知:子数组的最大值是有范围的,根据目标元素的特质,问题转化为确定一个有范围的整数
// 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;
}
// 2.寻找左侧边界的二分搜索
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;
}
}
// 最后要检查 left 越界的情况
if (left >= nums.length || nums[left] != target)
return -1;
return left;
}
// 3.寻找右侧边界的二分查找
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;
}
}
// 最后要检查 right 越界的情况
if (right < 0 || nums[right] != target)
return -1;
return right;
}
遇到子串问题,首先想到的就是滑动窗口技巧。
大致逻辑:
int left = 0, right = 0;
while (right < s.size()) {
// 增大窗口
window.add(s[right]);
right++;
while (window needs shrink) {
// 缩小窗口
window.remove(s[left]);
left++;
}
}
算法框架:
注意区间是左闭右开的
/* 滑动窗口算法框架 */
void slidingWindow(string s, string t) {
/* 滑动窗口算法框架 */
Map<Character, Integer> need = new HashMap<>();
Map<Character, Integer> window = new HashMap<>();
char[] tchars = t.toCharArray();
for (char c : tchars) {
need.put(c, need.getOrDefault(c, 0) + 1);
}
int left = 0, right = 0;
int valid = 0;
while (right < s.length()) {
// c 是将移入窗口的字符
char c = s.charAt(right);
// 右移窗口
right++;
// 进行窗口内数据的一系列更新
...
/*** debug 输出的位置 ***/
System.out.printf("window: [%d, %d)\n", left, right);
/********************/
// 判断左侧窗口是否要收缩
while (window needs shrink){
// d 是将移出窗口的字符
char d = s.charAt(left);
// 左移窗口
left++;
// 进行窗口内数据的一系列更新
...
}
}
}
套模板,只需要思考以下四个问题:
常见问题:
一个坑:Integer类型的数据在-128~127之间时,会使用缓存,用==比较时会比较数值,超出这个范围不会拆箱,比较的是对象,==返回的是false
要使用compareTo,或者equals
上述四个问题:
- 更新window,valid,其中 valid 变量表示窗口中满足 need 条件的字符个数
- valid==need.size()时,开始缩小
- 更新window,valid
- 缩小窗口时
- 更新window,valid
- right-left==s1.lenth()
- 更新window,valid
- 缩小时
首先简单阐述一下递归,分治算法,动态规划,贪心算法这几个东西的区别和联系,心里有个印象就好。
递归是一种编程技巧,一种解决问题的思维方式;分治算法和动态规划很大程度上是递归思想基础上的(虽然实现动态规划大都不是递归了,但是我们要注重过程和思想),解决更具体问题的两类算法思想;贪心算法是动态规划算法的一个子集,可以更高效解决一部分更特殊的问题。
分治算法将在这节讲解,以最经典的归并排序为例,它把待排序数组不断二分为规模更小的子问题处理,这就是“分而治之”这个词的由来。显然,排序问题分解出的子问题是不重复的,如果有的问题分解后的子问题有重复的(重叠子问题性质),那么这就交给动态规划算法去解决!
动态规划的特点:
难点在于:
写出状态转移方程是最困难的
明确「状态」 -> 定义 dp 数组/函数的含义 -> 明确「选择」-> 明确 base case
PS:但凡遇到需要递归的问题,最好都画出递归树,这对你分析算法的复杂度,寻找算法低效的原因都有巨大帮助
常见问题:
带备忘录的递归解法的效率已经和迭代的动态规划解法一样了。实际上,这种解法和迭代的动态规划已经差不多了,只不过这种方法叫做「自顶向下」,动态规划叫做「自底向上」
通过「备忘录」或者「dp table」的方法来优化递归树,并且明确了这两种方法本质上是一样的,只是自顶向下和自底向上的不同而已。
流程化确定「状态转移方程」,只要通过状态转移方程写出暴力递归解,剩下的也就是优化递归树,消除重叠子问题而已。
如何列出正确的状态转移方程?
1.先确定「状态」
2.然后确定 dp 函数的定义
3.然后确定「选择」并择优
4.最后明确 base case
BFS的特性:第一次遍历到目的节点,其所经过的路径为最短路径
所以BFS通常用来求解最短路径等最优解问题
因此,在搜索的时候常常要操作遍历的层数
DFS特性:从一个节点出发,使用 DFS 对一个图进行遍历时,能够遍历到的节点都是从初始节点可达的
所以DFS 常用来求解这种可达性问题
因此,常常需要将这种可达性标记出来
https://leetcode-cn.com/problems/n-ary-tree-level-order-traversal/solution/ncha-shu-de-ceng-xu-bian-li-by-leetcode/
https://leetcode-cn.com/problems/perfect-squares/solution/python3zui-ji-chu-de-bfstao-lu-dai-ma-gua-he-ru-me/
BFS 算法组成的 3 元素:队列,入队出队的节点,已访问的集合。
1.队列:先入先出的容器
2.节点:最好写成单独的类,或者pair之类
3.已访问集合:为了避免队列中插入重复的值
BFS套路
1.初始化三元素
2.操作队列 —— 弹出队首节点
3.操作弹出的节点 —— 根据业务生成子节点(一个或多个)
4.判断这些节点 —— 符合业务条件,则return,不符合业务条件,且不在已访问集合,则追加到队尾,并加入已访问集合
5.若以上遍历完成仍未return,下面操作返回未找到代码
// 结果集合:可能是数组,也可以是别的值
List<Integer> values = new ArrayList<>();
int path;
// 队列
Queue<Node> queue = new LinkedList<>();
// 已访问集合
int[] visited = new int[n + 1];
queue.add(root);
int[1] =1;
int path = 0;
while (!queue.isEmpty()) {
// 和层数有关的操作(如果和层数无关,则不需要这一层for)
path++;
int size = queue.size();
// 遍历一层中所有的节点
for (int i = 0; i < size; i++) {
// 操作当前节点
Node nextNode = queue.remove();
values.add(nextNode.val);
// 遍历子节点
for (Node child : nextNode.children) {
// 有时候遍历的不是节点,要判断是否符合题目条件和判断是否没有访问过
// 添加到队列中
queue.add(child);
}
}
}
DFS可以用递归实现,也可以借用一个栈迭代实现
递归的框架和回溯很像
// 标记节点是否访问过
private boolean[] visited;
public void DFS(顶点)
{
if(结束条件){
return;
}
处理当前顶点;
记录为已访问;
// 和回溯法不同的是,这里遍历的是状态,而不是选择,所以不需要撤回选择
遍历与当前顶点相邻的所有未访问顶点
{
DFS( 下一子状态);
}
}
回溯法是求问题的解,使用的是DFS(深度优先搜索)。在DFS的过程中发现不是问题的解,那么就开始回溯到上一层或者上一个节点。DFS是遍历整个搜索空间,而不管是否是问题的解
https://leetcode-cn.com/problems/maximum-depth-of-n-ary-tree/solution/c-san-chong-jing-dian-fang-fa-jie-ti-by-pris_bupt/
https://leetcode-cn.com/problems/path-sum/solution/lu-jing-zong-he-by-leetcode/
int maxDepth(Node* root) {
if (!root) return 0;
stack<pair<Node*,int>>stack;
stack.push(pair<Node*, int>(root,1));
int max_depth = 0;
while (!stack.empty()) {
Node* node = stack.top().first;
int depth = stack.top().second;
stack.pop();
for (Node* it : node->children)
stack.push(pair<Node*, int>(it, depth + 1));
max_depth = max(max_depth, depth);
}
return max_depth;
}
解决一个回溯问题,实际上就是一个决策树的遍历过程.
你只需要思考 3 个问题:
1、 路径:也就是已经做出的选择。
2、 选择列表:也就是你当前可以做的选择。
3、 结束条件:也就是到达决策树底层,无法再做选择的条件。
但是必须说明的是,不管怎么优化,都符合回溯框架,而且时间复杂度都不可能低于 O(N!),因为穷举整棵决策树是无法避免的。这也是回溯算法的一个特点,不像动态规划存在重叠子问题可以优化,回溯算法就是纯暴力穷举,复杂度一般都很高。
框架:
result = []
def backtrack(路径, 选择列表):
if 满足结束条件:
result.add(路径)
return
for 选择 in 选择列表:
排除不合法选择
做选择
backtrack(路径, 选择列表)
撤销选择
常见问题:
while (n > 0) {
int d = n % 10;
处理末位数字;
n = n / 10;
}
https://labuladong.gitbook.io/algo/di-ling-zhang-bi-du-xi-lie/hui-su-suan-fa-xiang-jie-xiu-ding-ban
https://oi-wiki.org/basic/divide-and-conquer/