通用原理
递归法
206. 单链表反转(递归法与迭代法)
//单链表反转
<?php
//单链表反转
/**
* Definition for a singly-linked list.
*/
class ListNode {
public $val = 0;
public $next = null;
function __construct($val = 0, $next = null) {
$this->val = $val;
$this->next = $next;
}
}
class Solution
{
/**
* @param ListNode $head
* @return ListNode
*/
/**
* 迭代法 为了减少时间复杂度 所以从头开始变
* 原始:(null),(1->2->3->4->5->null) head为1, new_head=null , old_head =1
* 过程1:(1->null) (2->3->4->5) 开始时候head为1 为了不和后面失联 要用
* A:old_head = head->next;//2
* B:head->next = new_head //null ,因为要改变head的指向 而且这里要用到new_head 所以不是先让new_head = head;
* C:new_head = head //1
* D:head = old_head //2 循环条件 以开始下面的循环
*/
function reverseList_1($head)
{
$new_head = null; //如果是java 可以这么写 ListNode new_head = null;//可以这样
$old_head = $head;
while($head !=null){
$old_head = $head->next;
$head->next = $new_head;
$new_head = $head;
$head = $old_head;
}
return $new_head;
}
/**
* 递归法
* 执行过程分析:
* |—— |—— |—— |—— |——
* | f(2) | f(3) |f(4) = |f(5) = |— 触发返回条件 return
* | do sth | | | |——
* f(1)= | .... = | = | |
* | return x | | |
* |—— |—— |—— |——
*
* 是否可以递归?若我们要置换(1,5) 那么就要置换(2,5) 所以是个递归的问题
*
* 如何写代码?(避免之前分析思路太长 直接程式化)
* 1,确定终止条件写在最前面,比如这里head->next或者head为null
* 2.递归调用函数(令返回值等于递归函数 结合236看)
* 3,从倒数第二个开始分析(因为f(5)已经有确定的返回值了) 也就是从f(4)(不管f4之前的 也就是假设f4是第一个函数)
* 写出4和5之间应该如何变化的状态转移方程(只考虑4到5如何替换 不考虑其他的 而且明确 这里的参数是对当前head而言的 与步骤2中的“new_head”无关) 然后填充到do sth中即可
* 4,看下应该return 什么东西 比如这里显然是return头结点
*/
public function reverseList_2($head){
if($head == null ||$head->next ==null) return $head;
//不需要 new_head = new ListNode(); php是弱类型 会根据head推出new_head的结构
$new_head = $this->reverseList($head->next);//注意这里是head.next
$head->next->next = $head;//此时的head为4 然后head.next.next不要理解成null 而是(head.next).next 第一次循环时 head-》next就是newhead 后面就不是了
$head->next = null;//设置尾部为null,且防止环形链表
return $new_head;
/*
* 面试官可以问循环里面的语句是否可以颠倒,如果颠倒了会是什么结果?
* 递归的解法同样,head.next.next = head 含义是什么,是否可以改成 newHead.next = head,会是什么结果?等等。。
*/
}
/**
* 头插法 相当于新建一个链表 不写了
*/
}
21. 合并两个有序链表
关于return L1的个人理解:
递归的核心在于,我只关注我这一层要干什么,返回什么,至于我的下一层(规模减1),我不管,我就是甩手掌柜.
好,现在我要merge L1,L2.我要怎么做?
显然,如果L1空或L2空,我直接返回L1或L2就行,这很好理解.
如果L1第一个元素小于L2的? 那我得把L1的这个元素放到最前面,至于后面的那串长啥样 ,我不管. 我只要接过下级员工干完活后给我的包裹, 然后把我干的活附上去(令L1->next = 这个包裹)就行
这个包裹是下级员工干的活,即merge(L1->next, L2)
我该返回啥?
现在不管我的下一层干了什么,又返回了什么给我, 我只要知道,假设我的工具人们都完成了任务, 那我的任务也就完成了,可以返回最终结果了
最终结果就是我一开始接手的L1头结点+下级员工给我的大包裹,要一并交上去, 这样我的boss才能根据我给它的L1头节点往下找,检查我完成的工作
class Solution {
public ListNode mergeTwoLists(ListNode l1, ListNode l2) {
if (l1 == null) {
return l2;
}
else if (l2 == null) {
return l1;
}
else if (l1.val < l2.val) {
l1.next = mergeTwoLists(l1.next, l2);
return l1;
}
else {
l2.next = mergeTwoLists(l1, l2.next);
return l2;
}
}
}
和之前其实是一样的套路 只不过这里不需要新生成一个返回的变量了
下面这种方法 以后有空再看
//有序链表的合并 JAVA 还有种递归法 看不懂 放弃
/**
\* 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 mergeTwoLists(ListNode l1, ListNode l2) {
// 类似归并排序中的合并过程
ListNode dummyHead = new ListNode(0);
//cur刚开始是头结点的引用 后面由于重新赋值了 后面就不是头结点的引用了
//那为什么要新弄一个cur呢?因为头结点要保留信息 cur用于后续的处理
ListNode cur = dummyHead;
//若两条都不为空
while (l1 != null && l2 != null) {
if (l1.val < l2.val) {
cur.next = l1;
cur = cur.next;
l1 = l1.next;
} else {
cur.next = l2;
cur = cur.next;
l2 = l2.next;
}
}
// 任一为空,就直接连接另一条链表
if (l1 == null) {
cur.next = l2;
} else {
cur.next = l1;
}
return dummyHead.next;
}
}
//PHP的代码
/**
* Definition for a singly-linked list.
* class ListNode {
* public $val = 0;
* public $next = null;
* function __construct($val = 0, $next = null) {
* $this->val = $val;
* $this->next = $next;
* }
* }
*/
class Solution {
/**
* @param ListNode $l1
* @param ListNode $l2
* @return ListNode
*/
//注意 这种方法假设两条都是有序链表
function mergeTwoLists($l1, $l2) {
$dummyNode = new ListNode;
$tmp = $dummyNode;
//当有一条为空的时候退出循环
while($l1!=null & $l2!=null){
if($l1->val<$l2->val){
$tmp->next = $l1;
$tmp = $tmp->next;
$l1 = $l1->next;
}
//写else的好处是 不需要每次都重新判断 只会执行其中一条的if
else if($l1->val>$l2->val){
$tmp->next = $l2;
$tmp = $tmp->next;
$l2 = $l2->next;
}
//这里这种相等的情况其实可以和上面的合并 else if($l1->val>=$l2->val) 即可
if($l1->val == $l2->val){
$tmp->next = $l1;
$tmp = $tmp->next;
//不能吧$l1 写在后面和l2一起 因为才没有新的赋值之前 改变$tmp->next会同步改变l1
$l1 = $l1->next;
$tmp->next = $l2;
$tmp = $tmp->next;
$l2 = $l2->next;
}
}
//if语句要写在下方 因为可能一方为空 若写在while上方 就会漏掉信息
if($l1==null) $tmp->next = $l2;
if($l2==null) $tmp->next = $l1;
return $dummyNode->next;
}
}
深度优先DFS和广度遍历BFS
二叉树的深度优先遍历(前序 中序 后序)
会迭代时看递归头疼,会递归后看迭代头疼
(一)先序遍历:先访问根节点,然后递归使用先序遍历访问左子树,再递归使用先序遍历右子树
根节点->左子树->右子树
(二)中序遍历:递归使用中序遍历访问左子树,然后访问根节点,最后再递归使用中序遍历访问右子树
左子树->根节点->右子树
(三)后序遍历:先递归使用后序遍历访问左子树和右子树,最后访问根节点
左子树->右子树->根节点
199. 前序遍历
/**
* Definition for a binary tree node.
* public class TreeNode {
* int val;
* TreeNode left;
* TreeNode right;
* TreeNode() {}
* TreeNode(int val) { this.val = val; }
* TreeNode(int val, TreeNode left, TreeNode right) {
* this.val = val;
* this.left = left;
* this.right = right;
* }
* }
*/
class Solution {
private List<Integer> ret = new ArrayList<>();//可在这里实例化
public List<Integer> preorderTraversal(TreeNode root) {
dfs(root);
return ret;
}
//由于上面的方法返回的是List类型 所以需要新设一个函数 不然不好递归
public void dfs(TreeNode root){
if(root == null) return;
ret.add(root.val);
dfs(root.left);
dfs(root.right);
}
}
也可这样 但是明显不如第一种优雅 以此体会面向对象 多个函数都会用到的属性 建议设置为成员变量
class Solution {
public List<Integer> preorderTraversal(TreeNode root) {
List<Integer> res = new ArrayList<Integer>();
preorder(root, res);
return res;
}
public void preorder(TreeNode root, List<Integer> res) {
if (root == null) {
return;
}
res.add(root.val);
preorder(root.left, res);
preorder(root.right, res);
}
}
还有迭代法和Morris 遍历,以后有兴趣再看
94. 中序遍历
class Solution {
public List<Integer> inorderTraversal(TreeNode root) {
List<Integer> res = new ArrayList<Integer>();
inorder(root, res);
return res;
}
public void inorder(TreeNode root, List<Integer> res) {
if (root == null) {
return;
}
inorder(root.left, res);
res.add(root.val);
inorder(root.right, res);
}
}
145. 后序遍历
class Solution {
public List<Integer> postorderTraversal(TreeNode root) {
List<Integer> res = new ArrayList<Integer>();
postorder(root, res);
return res;
}
public void postorder(TreeNode root, List<Integer> res) {
if (root == null) {
return;
}
postorder(root.left, res);
postorder(root.right, res);
res.add(root.val);
}
}
DFS与岛屿问题(拓展为图)
转载自 https://leetcode.cn/problems/number-of-islands/solutions/211211/dao-yu-lei-wen-ti-de-tong-yong-jie-fa-dfs-bian-li-/
刷了两百多题了,这个题解真的是我见过写的最清晰,最牛批的题解,没有之一。循序渐进,娓娓道来。
在 LeetCode 中,「岛屿问题」是一个系列系列问题,比如:
- L200. 岛屿数量 (Easy)
-
- 岛屿的周长 (Easy)
-
- 岛屿的最大面积 (Medium)
-
- 最大人工岛 (Hard)
我们所熟悉的 DFS(深度优先搜索)问题通常是在树或者图结构上进行的。而我们今天要讨论的 DFS 问题,是在一种「网格」结构中进行的。岛屿问题是这类网格 DFS 问题的典型代表。网格结构遍历起来要比二叉树复杂一些,如果没有掌握一定的方法,DFS 代码容易写得冗长繁杂。
本文将以岛屿问题为例,展示网格类问题 DFS 通用思路,以及如何让代码变得简洁。
网格问题的基本概念
我们首先明确一下岛屿问题中的网格结构是如何定义的,以方便我们后面的讨论。
网格问题是由 m×n
个小方格组成一个网格,每个小方格与其上下左右四个方格认为是相邻的,要在这样的网格上进行某种搜索。
岛屿问题是一类典型的网格问题。每个格子中的数字可能是 0 或者 1。我们把数字为 0 的格子看成海洋格子,数字为 1 的格子看成陆地格子,这样相邻的陆地格子就连接成一个岛屿。
在这样一个设定下,就出现了各种岛屿问题的变种,包括岛屿的数量、面积、周长等。不过这些问题,基本都可以用 DFS 遍历来解决。
DFS 的基本结构
网格结构要比二叉树结构稍微复杂一些,它其实是一种简化版的图结构。要写好网格上的 DFS 遍历,我们首先要理解二叉树上的 DFS 遍历方法,再类比写出网格结构上的 DFS 遍历。我们写的二叉树 DFS 遍历一般是这样的:
void traverse(TreeNode root) {
// 判断 base case
if (root == null) {
return;
}
// 访问两个相邻结点:左子结点、右子结点
traverse(root.left);
traverse(root.right);
}
可以看到,二叉树的 DFS 有两个要素:「访问相邻结点」和「判断 base case」。
第一个要素是访问相邻结点。二叉树的相邻结点非常简单,只有左子结点和右子结点两个。二叉树本身就是一个递归定义的结构:一棵二叉树,它的左子树和右子树也是一棵二叉树。那么我们的 DFS 遍历只需要递归调用左子树和右子树即可。
第二个要素是 判断 base case。一般来说,二叉树遍历的 base case 是 root == null
。这样一个条件判断其实有两个含义:一方面,这表示 root 指向的子树为空,不需要再往下遍历了。另一方面,在 root == null 的时候及时返回,可以让后面的 root.left 和 root.right 操作不会出现空指针异常。
对于网格上的 DFS,我们完全可以参考二叉树的 DFS,写出网格 DFS 的两个要素:
首先,网格结构中的格子有多少相邻结点?答案是上下左右四个。对于格子 (r, c) 来说(r 和 c 分别代表行坐标和列坐标),四个相邻的格子分别是 (r-1, c)、(r+1, c)、(r, c-1)、(r, c+1)。换句话说,网格结构是「四叉」的。
其次,网格 DFS 中的 base case 是什么?从二叉树的 base case 对应过来,应该是网格中不需要继续遍历、grid[r][c]
会出现数组下标越界异常的格子,也就是那些超出网格范围的格子。
这一点稍微有些反直觉,坐标竟然可以临时超出网格的范围?这种方法我称为「先污染后治理」—— 甭管当前是在哪个格子,先往四个方向走一步再说,如果发现走出了网格范围再赶紧返回。这跟二叉树的遍历方法是一样的,先递归调用,发现 root == null 再返回。
这样,我们得到了网格 DFS 遍历的框架代码:
void dfs(int[][] grid, int r, int c) {
// 判断 base case
// 如果坐标 (r, c) 超出了网格范围,直接返回
if (!inArea(grid, r, c)) {
return;
}
// 访问上、下、左、右四个相邻结点
dfs(grid, r - 1, c);
dfs(grid, r + 1, c);
dfs(grid, r, c - 1);
dfs(grid, r, c + 1);
}
// 判断坐标 (r, c) 是否在网格中
boolean inArea(int[][] grid, int r, int c) {
return 0 <= r && r < grid.length
&& 0 <= c && c < grid[0].length;
}
如何避免重复遍历
网格结构的 DFS 与二叉树的 DFS 最大的不同之处在于,遍历中可能遇到遍历过的结点。这是因为,网格结构本质上是一个「图」,我们可以把每个格子看成图中的结点,每个结点有向上下左右的四条边。在图中遍历时,自然可能遇到重复遍历结点。
这时候,DFS 可能会不停地「兜圈子」,永远停不下来,如下图所示:
如何避免这样的重复遍历呢?答案是标记已经遍历过的格子。以岛屿问题为例,我们需要在所有值为 1 的陆地格子上做 DFS 遍历。每走过一个陆地格子,就把格子的值改为 2,这样当我们遇到 2 的时候,就知道这是遍历过的格子了。也就是说,每个格子可能取三个值:
0 —— 海洋格子
1 —— 陆地格子(未遍历过)
2 —— 陆地格子(已遍历过)
我们在框架代码中加入避免重复遍历的语句:
void dfs(int[][] grid, int r, int c) {
// 判断 base case
if (!inArea(grid, r, c)) {
return;
}
// 如果这个格子不是岛屿,直接返回
if (grid[r][c] != 1) {
return;
}
grid[r][c] = 2; // 将格子标记为「已遍历过」
// 访问上、下、左、右四个相邻结点
dfs(grid, r - 1, c);
dfs(grid, r + 1, c);
dfs(grid, r, c - 1);
dfs(grid, r, c + 1);
}
// 判断坐标 (r, c) 是否在网格中
boolean inArea(int[][] grid, int r, int c) {
return 0 <= r && r < grid.length
&& 0 <= c && c < grid[0].length;
}
这样,我们就得到了一个岛屿问题、乃至各种网格问题的通用 DFS 遍历方法。以下所讲的几个例题,其实都只需要在 DFS 遍历框架上稍加修改而已。
小贴士:
在一些题解中,可能会把「已遍历过的陆地格子」标记为和海洋格子一样的
0,美其名曰「陆地沉没方法」,即遍历完一个陆地格子就让陆地「沉没」为海洋。这种方法看似很巧妙,但实际上有很大隐患,因为这样我们就无法区分「海洋格子」和「已遍历过的陆地格子」了。如果题目更复杂一点,这很容易出
bug。
作者:nettee
链接:https://leetcode.cn/problems/number-of-islands/solutions/211211/dao-yu-lei-wen-ti-de-tong-yong-jie-fa-dfs-bian-li-/
来源:力扣(LeetCode)
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
作者:nettee
链接:https://leetcode.cn/problems/number-of-islands/solutions/211211/dao-yu-lei-wen-ti-de-tong-yong-jie-fa-dfs-bian-li-/
来源:力扣(LeetCode)
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
作者:nettee
链接:https://leetcode.cn/problems/number-of-islands/solutions/211211/dao-yu-lei-wen-ti-de-tong-yong-jie-fa-dfs-bian-li-/
来源:力扣(LeetCode)
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
200. 岛屿数量
func numIslands(grid [][]byte) int {
count :=0
//其中 i 表示行,j 表示列
for i:=0;i<len(grid);i++{
for j:=0;j<len(grid[0]);j++{
if grid[i][j]=='1'{
dfs(grid,i,j)
count ++
}
}
}
return count;
}
func dfs(grid [][]byte ,i int,j int){
//防止 i 和 j 越界,也就是防止超出岛屿(上下左右)的范围。
if i<0||j<0||i>=len(grid)||j>=len(grid[0]){
return
}
//遇到海洋停止
if grid[i][j] !='1'{
return
}
grid[i][j]= '2'//默认都变成2 代表已经遍历过
dfs(grid,i+1,j)
dfs(grid,i-1,j)
dfs(grid,i,j+1)
dfs(grid,i,j-1)
}
695 岛屿的最大面积
LeetCode . Max Area of Island (Medium)
给定一个包含了一些 0 和 1 的非空二维数组 grid,一个岛屿是一组相邻的 1(代表陆地),这里的「相邻」要求两个 1 必须在水平或者竖直方向上相邻。你可以假设 grid 的四个边缘都被 0(代表海洋)包围着。
找到给定的二维数组中最大的岛屿面积。如果没有岛屿,则返回面积为 0 。
这道题目只需要对每个岛屿做 DFS 遍历,求出每个岛屿的面积就可以了。求岛屿面积的方法也很简单,代码如下,每遍历到一个格子,就把面积加一。
int area(int[][] grid, int r, int c) {
return 1
+ area(grid, r - 1, c)
+ area(grid, r + 1, c)
+ area(grid, r, c - 1)
+ area(grid, r, c + 1);
}
最终我们得到的完整题解代码如下:
public int maxAreaOfIsland(int[][] grid) {
int res = 0;
for (int r = 0; r < grid.length; r++) {
for (int c = 0; c < grid[0].length; c++) {
if (grid[r][c] == 1) {
int a = area(grid, r, c);
res = Math.max(res, a);
}
}
}
return res;
}
int area(int[][] grid, int r, int c) {
if (!inArea(grid, r, c)) {
return 0;
}
if (grid[r][c] != 1) {
return 0;
}
grid[r][c] = 2;
return 1
+ area(grid, r - 1, c)
+ area(grid, r + 1, c)
+ area(grid, r, c - 1)
+ area(grid, r, c + 1);
}
boolean inArea(int[][] grid, int r, int c) {
return 0 <= r && r < grid.length
&& 0 <= c && c < grid[0].length;
}
827. 填海造陆问题
LeetCode 827. Making A Large Island (Hard)
在二维地图上, 0 代表海洋,1代表陆地,我们最多只能将一格 0 (海洋)变成 1 (陆地)。进行填海之后,地图上最大的岛屿面积是多少?
这道题是岛屿最大面积问题的升级版。现在我们有填海造陆的能力,可以把一个海洋格子变成陆地格子,进而让两块岛屿连成一块。那么填海造陆之后,最大可能构造出多大的岛屿呢?
大致的思路我们不难想到,我们先计算出所有岛屿的面积,在所有的格子上标记出岛屿的面积。然后搜索哪个海洋格子相邻的两个岛屿面积最大。例如下图中红色方框内的海洋格子,上边、左边都与岛屿相邻,我们可以计算出它变成陆地之后可以连接成的岛屿面积为 7+1+2=107+1+2=107+1+2=10。
然而,这种做法可能遇到一个问题。如下图中红色方框内的海洋格子,它的上边、左边都与岛屿相邻,这时候连接成的岛屿面积难道是 7+1+77+1+77+1+7 ?显然不是。这两个 7 来自同一个岛屿,所以填海造陆之后得到的岛屿面积应该只有 7+1=87+1 = 87+1=8。
可以看到,要让算法正确,我们得能区分一个海洋格子相邻的两个 7 是不是来自同一个岛屿。那么,我们不能在方格中标记岛屿的面积,而应该标记岛屿的索引(下标),另外用一个数组记录每个岛屿的面积,如下图所示。这样我们就可以发现红色方框内的海洋格子,它的「两个」相邻的岛屿实际上是同一个。
可以看到,这道题实际上是对网格做了两遍 DFS:第一遍 DFS 遍历陆地格子,计算每个岛屿的面积并标记岛屿;第二遍 DFS 遍历海洋格子,观察每个海洋格子相邻的陆地格子。
这道题的基本思路就是这样,具体的代码还有一些需要注意的细节,但和本文的主题已经联系不大。各位可以自己思考一下如何把上述思路转化为代码。
463. 岛屿的周长
LeetCode 463. Island Perimeter (Easy)
给定一个包含 0 和 1 的二维网格地图,其中 1 表示陆地,0 表示海洋。网格中的格子水平和垂直方向相连(对角线方向不相连)。整个网格被水完全包围,但其中恰好有一个岛屿(一个或多个表示陆地的格子相连组成岛屿)。
岛屿中没有“湖”(“湖” 指水域在岛屿内部且不和岛屿周围的水相连)。格子是边长为 1 的正方形。计算这个岛屿的周长。
实话说,这道题用 DFS 来解并不是最优的方法。对于岛屿,直接用数学的方法求周长会更容易。不过这道题是一个很好的理解 DFS 遍历过程的例题,不信你跟着我往下看。
我们再回顾一下 网格 DFS 遍历的基本框架:
Java
void dfs(int[][] grid, int r, int c) {
// 判断 base case
if (!inArea(grid, r, c)) {
return;
}
// 如果这个格子不是岛屿,直接返回
if (grid[r][c] != 1) {
return;
}
grid[r][c] = 2; // 将格子标记为「已遍历过」
// 访问上、下、左、右四个相邻结点
dfs(grid, r - 1, c);
dfs(grid, r + 1, c);
dfs(grid, r, c - 1);
dfs(grid, r, c + 1);
}
// 判断坐标 (r, c) 是否在网格中
boolean inArea(int[][] grid, int r, int c) {
return 0 <= r && r < grid.length
&& 0 <= c && c < grid[0].length;
}
可以看到,dfs 函数直接返回有这几种情况:
!inArea(grid, r, c),即坐标 (r, c) 超出了网格的范围,也就是我所说的「先污染后治理」的情况
grid[r][c] != 1,即当前格子不是岛屿格子,这又分为两种情况:
grid[r][c] == 0,当前格子是海洋格子
grid[r][c] == 2,当前格子是已遍历的陆地格子
那么这些和我们岛屿的周长有什么关系呢?实际上,岛屿的周长是计算岛屿全部的「边缘」,而这些边缘就是我们在 DFS 遍历中,dfs 函数返回的位置。观察题目示例,我们可以将岛屿的周长中的边分为两类,如下图所示。黄色的边是与网格边界相邻的周长,而蓝色的边是与海洋格子相邻的周长。
当我们的 dfs 函数因为「坐标 (r, c) 超出网格范围」返回的时候,实际上就经过了一条黄色的边;而当函数因为「当前格子是海洋格子」返回的时候,实际上就经过了一条蓝色的边。这样,我们就把岛屿的周长跟 DFS 遍历联系起来了,我们的题解代码也呼之欲出:
Java
public int islandPerimeter(int[][] grid) {
for (int r = 0; r < grid.length; r++) {
for (int c = 0; c < grid[0].length; c++) {
if (grid[r][c] == 1) {
// 题目限制只有一个岛屿,计算一个即可
return dfs(grid, r, c);
}
}
}
return 0;
}
int dfs(int[][] grid, int r, int c) {
// 函数因为「坐标 (r, c) 超出网格范围」返回,对应一条黄色的边
if (!inArea(grid, r, c)) {
return 1;
}
// 函数因为「当前格子是海洋格子」返回,对应一条蓝色的边
if (grid[r][c] == 0) {
return 1;
}
// 函数因为「当前格子是已遍历的陆地格子」返回,和周长没关系
if (grid[r][c] != 1) {
return 0;
}
grid[r][c] = 2;
return dfs(grid, r - 1, c)
+ dfs(grid, r + 1, c)
+ dfs(grid, r, c - 1)
+ dfs(grid, r, c + 1);
}
// 判断坐标 (r, c) 是否在网格中
boolean inArea(int[][] grid, int r, int c) {
return 0 <= r && r < grid.length
&& 0 <= c && c < grid[0].length;
}
总结
对比完三个例题的题解代码,你会发现网格问题的代码真的都非常相似。其实这一类问题属于「会了不难」类型。了解树、图的基本遍历方法,再学会一点小技巧,掌握网格 DFS 遍历就一点也不难了。
作者:nettee
链接:https://leetcode.cn/problems/number-of-islands/solutions/211211/dao-yu-lei-wen-ti-de-tong-yong-jie-fa-dfs-bian-li-/
来源:力扣(LeetCode)
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
102. 二叉树的广度(层序)遍历
//二叉树的广度遍历 JAVA PHP不知为何 无法变成关联数组 很奇怪
/**
\* Definition for a binary tree node.
\* public class TreeNode {
\* int val;
\* TreeNode left;
\* TreeNode right;
\* TreeNode() {}
\* TreeNode(int val) { this.val = val; }
\* TreeNode(int val, TreeNode left, TreeNode right) {
\* this.val = val;
\* this.left = left;
\* this.right = right;
\* }
\* }
*/
遍历法
很多同学一看到「最短路径」,就条件反射地想到「Dijkstra 算法」。为什么 BFS 遍历也能找到最短路径呢?
这是因为,Dijkstra 算法解决的是带权最短路径问题,而我们这里关注的是无权最短路径问
作者:nettee
链接:https://leetcode.cn/problems/binary-tree-level-order-traversal/solutions/244853/bfs-de-shi-yong-chang-jing-zong-jie-ceng-xu-bian-l/
来源:力扣(LeetCode)
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
DFS 与 BFS逻辑区别
//让我们先看看在二叉树上进行 DFS 遍历和 BFS 遍历的思路代码比较。
//DFS 遍历使用 递归:
void dfs(TreeNode root) {
if (root == null) {
return;
}
dfs(root.left);
dfs(root.right);
}
//BFS 遍历使用队列数据结构:
void bfs(TreeNode root) {
Queue<TreeNode> queue = new ArrayDeque<>();
queue.add(root);
while (!queue.isEmpty()) {
TreeNode node = queue.poll(); // Java 的 pop 写作 poll()
if (node.left != null) {
queue.add(node.left);
}
if (node.right != null) {
queue.add(node.right);
}
}
}
class Solution {
public List<List<Integer>> levelOrder(TreeNode root) {
List<List<Integer>> res = new ArrayList<>();//这里实例化后还是[]而非[[]]
Queue<TreeNode> queue = new ArrayDeque<>();//queue存储左右节点 用linkedList也可
if(root != null){
queue.add(root);
}
while(!queue.isEmpty()){
//this_level将queue转换为需要的结构
List<Integer> this_level = new ArrayList<>();
//遍历当前的queue 然后将当前层级的下面的左右结点加入
int n = queue.size();
for(int i=0;i<n;i++){
TreeNode node = queue.poll();
this_level.add(node.val);
if(node.left!=null) queue.add(node.left);
if(node.right != null) queue.add(node.right);
}
res.add(this_level);
}
return res;
}
}
递归法(此处实际上是深度优先特殊处理输出广度优先遍历)
class Solution {
List<List<Integer>> ret = new ArrayList<>();
public List<List<Integer>> levelOrder(TreeNode root) {
if(root == null) return ret;
//这里另外定义一个函数是因为 level要纳入进去
traverse(root, 0);
return ret;
}
private void traverse(TreeNode root, int level){
//这里的root是会变化的 所以
if(root == null) return;
//若对应的层次没有小菱形 那么加一个外层空的
if(ret.size()-1 < level ) ret.add(new ArrayList<Integer>());
//加根节点 由于是遍历 所以要放到递归函数之前
ret.get(level).add(root.val);
traverse(root.left, level+1);
traverse(root.right, level+1);
}
}
golang实现
/**
* Definition for a binary tree node.
* type TreeNode struct {//注意这里没有*
* Val int
* Left *TreeNode
* Right *TreeNode
* }
*/
func levelOrder(root *TreeNode) [][]int {
ret := [][]int{} //没有长度 没有三个点 这是一个切片 且为[]([]int{}) 外面是数组 里面是[]int{}
if root==nil{
return ret
}
//由于go有动态数组 所以不需要使用java的queue
queue := []*TreeNode{root}//定义一个切片 里面放的是*TreeNode指针类型 并用root初始化
for i := 0; len(queue) > 0; i++ {//和java不同 没有isEmpty方法 所以用普通循环的形式
this_level :=[]int{}
last_queue_size := len(queue)
for j:=0;j<last_queue_size;j++{
node := queue[j]
this_level = append(this_level, node.Val)//append
if node.Left != nil {
queue = append(queue, node.Left)
}
if node.Right != nil {
queue = append(queue, node.Right)
}
}
queue = queue[last_queue_size:]//使用这个模拟java的pop
ret = append(ret, this_level)
}
return ret;
}
//或者用下面这种 更复合golang语法
func levelOrder(root *TreeNode) [][]int {
ret := [][]int{}
if root == nil {
return ret
}
this_level := []*TreeNode{root}
//这里看似两层循环没必要 其实外层的循环是内层循环结束后的next_leval,内层的才是this_level。而且这里是len(this_level) > 0 和下面不一样
for i := 0; len(this_level) > 0; i++ {
ret = append(ret, []int{})//因为下面要用到ret[i]
next_level := []*TreeNode{}//每次循环完清空next_level
for j := 0; j < len(this_level); j++ {
node := this_level[j]
ret[i] = append(ret[i], node.Val)//使用i的形式添加
if node.Left != nil {
next_level = append(next_level, node.Left)
}
if node.Right != nil {
next_level = append(next_level, node.Right)
}
}
this_level = next_level//更新
}
return ret
}
php版本
class Solution {
/**
* @param TreeNode $root
* @return Integer[][]
*/
function levelOrder($root) {
if($root == null) return [];//非[[]]
$this_level = [$root];
for($i=0;count($this_level)>0;$i++){
//$next_level = [[]];//不能写出这种 写成这种的话 下面必须要用array_merge
$next_level = [];//每次循环完清空next_level
for($j=0;$j<count($this_level);$j++){
$node = $this_level[$j];
$ret[$i][] = $node->val;
if($node->left!=null) $next_level[]=$node->left;
if($node->right!=null) $next_level[]=$node->right;
}
$this_level = $next_level;
}
return $ret;
}
}
变种-103.锯齿层序遍历
//锯齿遍历只用加这个即可
if ((level & 1) == 1){
//表示在头部插入
res.get(level).add(0, root.val);
} else {
res.get(level).add(root.val);
}
或者
boolean flag = false;
if(flag){
arr.add(0,node.val);//或者Collections.reverse(tempList);
}else{
arr.add(node.val);
}
}
flag=!flag;
或者
int j =1;
//-----------
if(++j%2 == 1){
Collections.reverse(this_level);
}
199. 二叉树的右视图(BFS和DFS法)
BFS法
/**
* Definition for a binary tree node.
* public class TreeNode {
* int val;
* TreeNode left;
* TreeNode right;
* TreeNode() {}
* TreeNode(int val) { this.val = val; }
* TreeNode(int val, TreeNode left, TreeNode right) {
* this.val = val;
* this.left = left;
* this.right = right;
* }
* }
*/
class Solution {
public List<Integer> rightSideView(TreeNode root) {
List<Integer> ret = new ArrayList<>();
if(root==null) return ret;
Queue<TreeNode> queue = new LinkedList<>();
queue.offer(root);
while(!queue.isEmpty()){
int size = queue.size();
for(int i =0;i<size;i++){
TreeNode node = queue.poll();
if(node.left != null) queue.offer(node.left);
if(node.right != null) queue.offer(node.right);
if(i==size-1) ret.add(node.val);
}
}
return ret;
}
}
DFS法:
思路: 我们按照 「根结点 -> 右子树 -> 左子树」 的顺序访问,就可以保证每层都是最先访问最右边的节点的。
(与先序遍历 「根结点 -> 左子树 -> 右子树」 正好相反,先序遍历每层最先访问的是最左边的节点)
class Solution {
List<Integer> res = new ArrayList<>();
public List<Integer> rightSideView(TreeNode root) {
dfs(root, 0); // 从根节点开始访问,根节点深度是0
return res;
}
private void dfs(TreeNode root, int depth) {
if (root == null) return;
// 如果当前节点所在深度还没有出现在res里,说明在该深度下当前节点是第一个被访问的节点,
//因此将当前节点加入res中。
if (depth == res.size()) res.add(root.val);
depth++;
dfs(root.right, depth);
dfs(root.left, depth);
}
}
236. 二叉树的最近公共祖先
- 递归解析:
- 终止条件:
- 当越过叶节点,则直接返回 null ;
-当 root 等于 p,q ,则直接返回 root ;
- 当越过叶节点,则直接返回 null ;
- 递推工作:
- 开启递归左子节点,返回值记为 left;
- 开启递归右子节点,返回值记为 right ;
- 返回值: 根据 left 和 right ,可展开为四种情况;
- 当 left 和 right 同时为空 :说明 root 的左 / 右子树中都不包含 p,q,返回 null ;
- 当 left 和 right同时不为空 :说明 p,q 分列在 root的 异侧 (分别在 左 / 右子树),因此 root 为最近公共祖先,返回 root;
- 当 left 为空 ,right 不为空 :p,q都不在 root 的左子树中,直接返回 rightrightright 。具体可分为两种情况:
- p,qp,qp,q 其中一个在 roott 的 右子树 中,此时 right 指向 ppp(假设为 ppp );
- p,q两节点都在 root 的 右子树 中,此时的 right 指向 最近公共祖先节点 ;
- 当 left 不为空 , right为空 :与情况 3. 同理;
- 观察发现, 情况 1. 可合并至 3. 和 4. 内,详见文章末尾代码。
- 终止条件:
作者:Krahets
链接:https://leetcode.cn/problems/lowest-common-ancestor-of-a-binary-tree/solutions/240096/236-er-cha-shu-de-zui-jin-gong-gong-zu-xian-hou-xu/
来源:力扣(LeetCode)
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
class Solution {
public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) {
//只要当前根节点是p和q中的任意一个,就返回(终止条件只考虑第一层的root)
//(因为不能比这个更深了,再深p和q中的一个就没了)
if (root == null || root == p || root == q) return root;
//此处的left和right理解为 找到p和q的最近祖先 如果没找到 那就是null
//根节点不是p和q中的任意一个,那么就继续分别往左子树和右子树找p和q
TreeNode left = lowestCommonAncestor(root.left, p, q);
TreeNode right = lowestCommonAncestor(root.right, p, q);
//p和q都没找到,那就没有
if(left == null && right == null) return null;
//左子树没有p也没有q,就返回右子树的结果
if (left == null) return right;
//右子树没有p也没有q就返回左子树的结果
if (right == null) return left;
//左右子树都找到p和q了,那就说明p和q分别在左右两个子树上,所以此时的最近公共祖先就是root
return root;
}
}
分治法
分治法指的是将原问题递归地分成若干个子问题,直到子问题满足边界条件,停止递归,将子问题逐个解决(一般是同种方法),将已经解决的子问题合并,最后,算法会层层合并得到原问题的答案。
分治算法步骤:
分:递归地将问题分解为各个的子问题(性质相同的,相互独立的子问题)。
治:将这些规模更小的子问题逐个击破。
合:将已解决的问题逐层合并,最终得出原问题的解。
递归只是处理办法,分治是手段。他们就像一对孪生兄弟,经常同时应用在算法设计中,并由此产生许多高效的算法。
分治可以用递归来实现,也可以用别的算法来实现。
前面那些似乎也算分治法?
分治法适用条件:
- 问题的规模缩小到一定的规模就可以较容易地解决。
- 问题可以分解为若干个规模较小的模式相同的子问题,即该问题具有最优子结构性质。
- 合并问题分解出的子问题的解可以得到问题的解。
- 问题所分解出的各个子问题之间是独立的,即子问题之间不存在公共的子问题。
补充题4 与 215.快速排序
力扣还有更多其他排序的题解
此处和其他地方的区别在于 之前的是先递归 后求解 类似栈的形式。此处是先排序 后写递归 递归其实不是这么重要了 也可以其他手段 不是那么的像栈了
class Solution {
public int[] sortArray(int[] nums) {
qucikSort(nums,0,nums.length-1);
return nums;
}
public void qucikSort(int[] nums,int low_index,int high_index){
if(low_index<high_index){
int standard_index = setSelectedToPosition(nums,low_index,high_index);//这里也可以把函数直接写出来 但是为了体现分治法 就单独写了
qucikSort(nums,low_index,standard_index - 1);
qucikSort(nums,standard_index + 1 , high_index);
}
}
public int setSelectedToPosition(int[] nums,int low_index,int high_index){
//随机快排法
Random random = new Random();
int selected_index = random.nextInt(high_index - low_index + 1) + low_index;//next生成的值在[0,num)
int selected_value = nums[selected_index];
//将选中的基准值交换到第一个位置
nums[selected_index] = nums[low_index];
nums[low_index] = selected_value;
/*
* 思路:比基准值小的得放左边 大的得放右边
* 如果使用普通的从左到右比较 那么得交换好几个数 而从右边到左边 只需要交换特定的两个数即可
* 当第一次交换完成后 基准值到了右边 所以 就变成 从左到右判断交换了
* 这样循环进行 while语句中的是一次循环
*
*/
while(low_index < high_index){//注意这个大括号
//1 还是得low_index < high_index 2 条件是大于等于 避免不必要的交换 否则会超时
while(low_index < high_index && nums[high_index]>=selected_value) {
high_index--;
}
nums[low_index]=nums[high_index];//本来还要high_val = low_val 但是判断是和selected 也就不必交换了
while(low_index < high_index && nums[low_index]<=selected_value) low_index++;
//此时high的位置本来是selected_val的
nums[high_index] = nums[low_index];
}
nums[low_index] = selected_value;//注意这里
return low_index;
}
}
动态规划法
与递归法的主要区别是,是记录下之前的状态(核心),以减少重复的运算。前面的状态对后面的状态有帮助 需要有个状态方程(其实有点像数学归纳法)
而为了记录下状态,所以,动态规划是自底向上的,而递归法是自顶向下的。
而在这里 确定子问题的关键是 无后效性
53 最大子序和
假设 输入数组是 [-2,1,-3,4,-1,2,1,-5,4]
动态规划首先需要 明确子问题 我们可能很容易 设子问题是下面这样的
- 子问题 1:经过 −2的连续子数组的最大和是多少;
- 子问题 2:经过 1 的连续子数组的最大和是多少;
- ……
这些子问题之间的联系并没有那么好看出来,这是因为 子问题的描述还有不确定的地方(这件事情叫做「有后效性」,我们在后面会讲解什么是「无后效性」)。
例如「子问题 2」:经过 1 的连续子数组的最大和是多少。
我们任意举出几个:
- [-2,1,-3,4] ;
- [1,-3,4,-1] ;
这就不方便我们去解决问题。
所以,我们将子问题修改为:
- 子问题 1:以 -2 结尾的连续子数组的最大和是多少;
- 子问题 2:以 1 结尾的连续子数组的最大和是多少;
- ……
于是,我们就可以得到,子问题1的答案就是-2
子问题2的答案取决于子问题1的答案:
- 当子问题1大于0时,加上子问题1会更大
- 当子问题2小于等于0时,以1结尾会更大
于是我们就能写出,描述子问题之间关系的状态转移方程:
或者:
最后再谈谈「无后效性」
「无后效性」是我多次提到的一个「动态规划」中非常重要的概念,在我看来,理解这个概念无比重要。很遗憾,《算法导论》上没有讲到「无后效性」。我找了一本在「豆瓣」目前豆瓣上评分为 9.2 的书 《算法竞赛进阶指南》,这本书和《算法导论》《算法 4》和 liuyubobobo 老师的算法课程一样,在我学习算法与数据结构的道路上,都发挥了巨大的作用。
李煜东著《算法竞赛进阶指南》,摘录如下::
为了保证计算子问题能够按照顺序、不重复地进行,动态规划要求已经求解的子问题不受后续阶段的影响。这个条件也被叫做「无后效性」。换言之,动态规划对状态空间的遍历构成一张有向无环图,遍历就是该有向无环图的一个拓扑序。有向无环图中的节点对应问题中的「状态」,图中的边则对应状态之间的「转移」,转移的选取就是动态规划中的「决策」。
「有向无环图」「拓扑序」表示了每一个子问题只求解一次,以后求解问题的过程不会修改以前求解的子问题的结果;
换句话说:如果之前的阶段求解的子问题的结果包含了一些不确定的信息,导致了后面的阶段求解的子问题无法得到,或者很难得到,这叫「有后效性」,我们在当前这个问题第 1 次拆分的子问题就是「有后效性」的(大家可以再翻到上面再看看);
解决「有后效性」的办法是固定住需要分类讨论的地方,记录下更多的结果。在代码层面上表现为:
- 状态数组增加维度,例如:「力扣」的股票系列问题;
- 把状态定义得更细致、准确,例如:前天推送的第 124 题:状态定义只解决路径来自左右子树的其中一个子树。
作者:liweiwei1419
链接:https://leetcode.cn/problems/maximum-subarray/solutions/9058/dong-tai-gui-hua-fen-zhi-fa-python-dai-ma-java-dai/
来源:力扣(LeetCode)
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
以下「参考代码 1」给出了不空间优化的代码,「参考代码 2」给出了空间优化的代码。
参考代码 1:
public class Solution {
public int maxSubArray(int[] nums) {
int len = nums.length;
// dp[i] 表示:以 nums[i] 结尾的连续子数组的最大和
int[] dp = new int[len];
dp[0] = nums[0];
for (int i = 1; i < len; i++) {
if (dp[i - 1] > 0) {
dp[i] = dp[i - 1] + nums[i];
} else {
dp[i] = nums[i];
}
}
// 也可以在上面遍历的同时求出 res 的最大值,这里我们为了语义清晰分开写,大家可以自行选择
int res = dp[0];
for (int i = 1; i < len; i++) {
res = Math.max(res, dp[i]);
}
return res;
}
}
时间复杂度:O(N) ,这里 N是输入数组的长度。
参考代码 2:
public class Solution {
public int maxSubArray(int[] nums) {
int pre = 0;
int res = nums[0];
for (int num : nums) {
pre = Math.max(pre + num, num);
res = Math.max(res, pre);
}
return res;
}
}
时间复杂度:O(N)
这里再多说一点,如果是在 online judge 上写代码,我一般都不会写优化空间的代码,这是因为:
一般的问题只要时间复杂度最优就可以;
空间复杂度 online judge 并不在意,只要使用的空间不太离谱,不要一上来就 int[] dp = new int[Integer.MAX_VALUE] 就好;
优化空间的代码会丢失可读性,不好理解和向他人阐述。我自己写出来都困难,一般的流程是:先写一版不优化空间的代码,再写优化空间的代码。但是不优化空间的代码都可以通过系统测评了,我为什么还要写优化空间的代码呢?哈哈哈。
还有个分治法 不过这题我觉得分治法比较那啥 之后想看 去力扣看吧
public class Solution {
public int maxSubArray(int[] nums) {
int len = nums.length;
if (len == 0) {
return 0;
}
return maxSubArraySum(nums, 0, len - 1);
}
private int maxCrossingSum(int[] nums, int left, int mid, int right) {
// 一定会包含 nums[mid] 这个元素
int sum = 0;
int leftSum = Integer.MIN_VALUE;
// 左半边包含 nums[mid] 元素,最多可以到什么地方
// 走到最边界,看看最值是什么
// 计算以 mid 结尾的最大的子数组的和
for (int i = mid; i >= left; i--) {
sum += nums[i];
if (sum > leftSum) {
leftSum = sum;
}
}
sum = 0;
int rightSum = Integer.MIN_VALUE;
// 右半边不包含 nums[mid] 元素,最多可以到什么地方
// 计算以 mid+1 开始的最大的子数组的和
for (int i = mid + 1; i <= right; i++) {
sum += nums[i];
if (sum > rightSum) {
rightSum = sum;
}
}
return leftSum + rightSum;
}
private int maxSubArraySum(int[] nums, int left, int right) {
if (left == right) {
return nums[left];
}
int mid = left + (right - left) / 2;
return max3(maxSubArraySum(nums, left, mid),
maxSubArraySum(nums, mid + 1, right),
maxCrossingSum(nums, left, mid, right));
}
private int max3(int num1, int num2, int num3) {
return Math.max(num1, Math.max(num2, num3));
}
}
70. 爬楼梯
普通递归法
- 当n==1时,显然,方法数为f(1)==1
- 当n==2时,有两种情况:第一种是每次走一步,第二种是一次跨两步,所以方法数为f(2)==2
- 当n==k(k>2)时,如何用到之前的状态呢?我们发现,在最后一跨的时候,你可以选择跨一步或者两步,所以,为f(k-2)+f(k-1)种方法
class Solution {
public int climbStairs(int n) {
if(n<=2) return n;
return climbStairs(n-1) + climbStairs(n-2);
}
}
时间复杂度为O(2n),当然会“超出时间限制”。
这个算法的空间复杂度也不低,能达到O(n)
动态规划法(滚动(滑动)数组优化空间复杂度)
为了避免上述代码的重复运算,我们自然很容易想到用一个长度为n的数组保存下计算状态。但由于我们实际只用到了n-1和n-2两个地方,所以,用两个变量存储就够了。
class Solution {
public int climbStairs(int n) {
int p=0,q=0;
int ret = 1;
//不能从3开始,这样1和2的情况就得单独处理了 不划算 增加代码量
for(int i=1 ;i<=n ;i++){
p=q;
q=ret;
ret = p+q;
}
return ret;
}
}
二分递归法
二分递归区别于普通递归的地方,在于:它从原来的考虑f(n)与f(n-1)的关系,转变为考虑f(n)与f(n/2)的关系 按照以上思路,则可以分为两种情况:
- 我们假设到达第n个位置经过了第n/2个位置,所以从开始位置到达第n/2个位置的方法数为:f(n/2),从第n/2个位置到达第n个位置的方法数为:f(n-n/2),那么此时总的方法数为:f(n/2)*f(n-n/2)
- 我们假设到达第n个位置越过了第n/2个位置,所以从开始位置到达第n/2-1个位置的方法数为:f(n/2-1),之后一次跨两步到达了第n/2+1个位置,从第n/2+1个位置到达第n个位置的方法数为:f(n-n/2-1),那么此时总的方法数为:f(n/2-1)*f(n-n/2-1)
最终的代码如下:
class Solution {
public:
int climbStairs(int n) {
if (n <= 2) return n;
return climbStairs(n/2) * climbStairs(n-n/2) + climbStairs(n/2-1) * climbStairs(n-n/2-1);
}
};
运行之后发现报错了。肿么回事呢? 我们先把n==3代入看看, f(3)==f(1)*f(2)+f(0)*f(1) 我们发现,n==0这种情况是我们没有考虑到的,当n==0的时候,起始位置即终止位置,所以我们不需要跨步,方法数为1,因此我们应该返回1 所以,添加了这种情况之后,真正的最终代码如下:
class Solution {
public:
int climbStairs(int n) {
if (n == 0) return 1;
if (n <= 2) return n;
return climbStairs(n/2) * climbStairs(n-n/2) + climbStairs(n/2-1) * climbStairs(n-n/2-1);
}
};
其他方法
- 矩阵快速幂法
- 通项公式法
数学 如果观察数学规律,可知本题是斐波那契数列,那么用斐波那契数列的公式即可
42. 接雨水–dp与双指针法
见力扣五解法的
300. 最长递增子序列
前两个精选题解他们一开始都不是现在这样解释的,正是因为他们看到了这个问题解释不清楚,才会引入了更严谨的数学表达。先看这篇题解有一个感性认识,再看前两篇题解就知道其实他们讲得更明白,讲清楚了方法二应该如何去思考。有的时候,严谨和通俗不能同时满足,有些人会追求通俗,有些人会追求严谨(官方题解更是如此)。
动态规划法
使用数组 cell 保存每步子问题的最优解。
- cell[i] 代表含第 i 个元素的最长上升子序列的长度,初始值为1
- 求解 cell[i] 时,向前遍历找出比 i 元素小的元素 j,每次遍历都令 cell[i] 为 max(cell[i],cell[j]+1),最终求出celi[i]
class Solution {
public int lengthOfLIS(int[] nums) {
if (nums.length == 0) {
return 0;
}
int[] dp = new int[nums.length];
dp[0] = 1;
int maxans = 1;
for (int i = 1; i < nums.length; i++) {
dp[i] = 1;
for (int j = 0; j < i; j++) {
if (nums[i] > nums[j]) {
dp[i] = Math.max(dp[i], dp[j] + 1);
}
}
maxans = Math.max(maxans, dp[i]);
}
return maxans;
}
}
复杂度分析:
时间复杂度:O(n2),双层遍历
空间复杂度:O(n)
动态规划+二分查找法
-
新建数组 cell,用于保存最长上升子序列。
-
对原序列进行遍历,将每位元素二分插入 cell 中。
-
如果 cell 中元素都比它小,将它插到最后。否则,用它覆盖掉比它大的元素中最小的那个。
你硬更新进去的数组,如果没有超出已有长度,那么它不影响你的计长,而如果超出了,那么它就会是新的可行的最长升序子序列,而且相比于你被覆盖掉的那个绝对最优。(特别对最后一位而言 如 124536456)
- 总之,思想就是让 cell 中存储比较小的元素。这样,cell 未必是真实的最长上升子序列,但长度是对的。
class Solution {
public int lengthOfLIS(int[] nums) {
if(nums.length ==0){
return 0;
}
int[] dp = new int[nums.length];
dp[0]=nums[0];
int dp_target = 0;
for(int i=1;i<nums.length;i++){
if(nums[i]>dp[dp_target]){
dp[++dp_target] = nums[i];
}
else{
int a = search(dp,nums[i],dp_target);
dp[a] = nums[i];
}
//System.out.println(Arrays.toString(dp));//这样可以快捷的打印
}
return dp_target+1;
}
public int search(int[] nums,int target,int right){
int left=0 ;
int mid;
while(left<=right){
mid = (left+right)>>1;
if(target>nums[mid]){
left = mid +1;
}
else{
right = mid-1 ;
}
}
return left;//而非return mid
}
}
72 编辑距离
此题无需再看力扣题解了
编辑距离算法被数据科学家广泛应用,是用作机器翻译和语音识别评价标准的基本算法。
最直观的方法是暴力检查所有可能的编辑方法,取最短的一个。所有可能的编辑方法达到指数级,但我们不需要进行这么多计算,因为我们只需要找到距离最短的序列而不是所有可能的序列。
思路和算法
我们可以对任意一个单词进行三种操作:
-
插入一个字符;
-
删除一个字符;
-
替换一个字符。
题目给定了两个单词,设为 A 和 B,这样我们就能够六种操作方法。
但我们可以发现,如果我们有单词 A 和单词 B:
对单词 A 删除一个字符和对单词 B 插入一个字符是等价的。例如当单词 A 为 doge,单词 B 为 dog 时,我们既可以删除单词 A 的最后一个字符 e,得到相同的 dog,也可以在单词 B 末尾添加一个字符 e,得到相同的 doge;
同理,对单词 B 删除一个字符和对单词 A 插入一个字符也是等价的;
对单词 A 替换一个字符和对单词 B 替换一个字符是等价的。例如当单词 A 为 bat,单词 B 为 cat 时,我们修改单词 A 的第一个字母 b -> c,和修改单词 B 的第一个字母 c -> b 是等价的。
这样以来,本质不同的操作实际上只有三种:
-
在单词 A 中插入一个字符;
-
在单词 B 中插入一个字符;
-
修改单词 A 的一个字符。
我们定义 dp[i][j]
代表 word1 中前 i
个字符,变换到 word2 中前 j
个字符,最短需要操作的次数,这样,当word1变到word2需要
-
增时:
dp[i][j] = dp[i][j - 1] + 1
(即word2比word1多一个字符,比如a->ab,就相当于 a->a 然后加一个b) -
删时,
dp[i][j] = dp[i - 1][j] + 1
(可以理解为增加另一个) -
改时,
dp[i][j] = dp[i - 1][j - 1] + 1
(其实可以理解为 先各自删除一个字符, 再增加b种的那个字符)
只要我们,按顺序计算,当计算 dp[i][j]
时,dp[i - 1][j]
, dp[i][j - 1]
, dp[i - 1][j - 1]
均已能确定了,此时,状态转移方程为,dp[i][j] = min(增,删,改)
如果刚好这两个字母相同 word1[i - 1] = word2[j - 1]
,那么可以直接参考 dp[i - 1][j - 1]
,操作不用加一
此外,我们还要注意,需要考虑 word1 或 word2 一个字母都没有,即全增加/删除的情况,所以预留 dp[0][j]
和 dp[i][0]
(即使不是特殊情况 也要考虑边界值,以方便计算dp[1][1]
)
代码
//ps java二维数组 其实是这种形式
int[][] arr = { {0, 0, 0, 0, 0, 0}, {0, 0, 1, 0, 0, 0},
{0,2, 0, 3, 0, 0}, {0, 0, 0, 0, 0, 0} };
//自底向下法
class Solution {
public int minDistance(String word1, String word2) {
int n1 = word1.length();//和数组不同 这里要加括号
int n2 = word2.length();
int[][] dp = new int[n1 + 1][n2 + 1];
// 第一行
for (int j = 1; j <= n2; j++) dp[0][j] = dp[0][j - 1] + 1;
// 第一列
for (int i = 1; i <= n1; i++) dp[i][0] = dp[i - 1][0] + 1;
for (int i = 1; i <= n1; i++) {
for (int j = 1; j <= n2; j++) {
if (word1.charAt(i - 1) == word2.charAt(j - 1)) dp[i][j] = dp[i - 1][j - 1];//charAt以0开始
else dp[i][j] = Math.min(Math.min(dp[i - 1][j - 1], dp[i][j - 1]), dp[i - 1][j]) + 1;
}
}
return dp[n1][n2];
}
}
//自顶向下法
class Solution:
def minDistance(self, word1: str, word2: str) -> int:
import functools
@functools.lru_cache(None)
def helper(i, j):
if i == len(word1) or j == len(word2):
return len(word1) - i + len(word2) - j
if word1[i] == word2[j]:
return helper(i + 1, j + 1)
else:
inserted = helper(i, j + 1)
deleted = helper(i + 1, j)
replaced = helper(i + 1, j + 1)
return min(inserted, deleted, replaced) + 1
return helper(0, 0)
复杂度分析
时间复杂度 :O(mn)
,其中 m 为 word1 的长度,n 为 word2 的长度。
空间复杂度 :O(mn),我们需要大小为 O(mn)的 dp数组来记录状态值。
5. 最长回文字串
public String longestPalindrome1(String s) {
if (s == null || s.length() == 0) {
return "";//s是可以为null的
}
int strLen = s.length();
int left = 0;
int right = 0;
int len = 1;
int maxStart = 0;
int maxLen = 0;
for (int i = 0; i < strLen; i++) {
left = i - 1;
right = i + 1;
while (left >= 0 && s.charAt(left) == s.charAt(i)) {
len++;
left--;
}
while (right < strLen && s.charAt(right) == s.charAt(i)) {
len++;
right++;
}
while (left >= 0 && right < strLen && s.charAt(right) == s.charAt(left)) {
len = len + 2;
left--;
right++;
}
if (len > maxLen) {
maxLen = len;
maxStart = left;
}
len = 1;
}
return s.substring(maxStart + 1, maxStart + maxLen + 1);
}
public String longestPalindrome(String s) {
if (s == null || s.length() < 2) {
return s;
}
int strLen = s.length();
int maxStart = 0; //最长回文串的起点
int maxEnd = 0; //最长回文串的终点
int maxLen = 1; //最长回文串的长度
boolean[][] dp = new boolean[strLen][strLen];
for (int r = 1; r < strLen; r++) {
for (int l = 0; l < r; l++) {
if (s.charAt(l) == s.charAt(r) && (r - l <= 2 || dp[l + 1][r - 1])) {
dp[l][r] = true;
if (r - l + 1 > maxLen) {
maxLen = r - l + 1;
maxStart = l;
maxEnd = r;
}
}
}
}
return s.substring(maxStart, maxEnd + 1);
}
双指针法
88. 合并有序数组( 合并 nums2 到 nums1 中)
力扣三种解法
1 插入后再排序
2 双指针头排序
3 双指针未排序
此题也可使用头排序 但是因为返回的是nums1 会造成覆盖问题 比较麻烦 所以使用尾排序
//一个细节 数组作为参数传递时 并不要求指定长度 这个长度由调用的函数确定
class Solution {
public void merge(int[] nums1, int m, int[] nums2, int n) {
int p1 =m-1 ,p2=n-1;
int tail = m+n-1;//注意这里是-1 比如1+1 数组tail是1而非0
int cur_val;
while(p1>=0||p2>=0){
if(p1==-1){
cur_val = nums2[p2--];//先赋值 再减1
}else if(p2==-1){
cur_val = nums1[p1--];
}else if(nums1[p1]>=nums2[p2]){
cur_val = nums1[p1--];
}else{
cur_val = nums2[p2--];
}
nums1[tail--] = cur_val;
}
}
}
golang解法 更加简洁
func merge(nums1 []int, m int, nums2 []int, n int) {
var i, j = m -1, n - 1
for k := m + n - 1; k >= 0; k-- {
if j < 0 || (i >= 0 && nums1[i] > nums2[j]) {
nums1[k] = nums1[i]
i--
} else {
nums1[k] = nums2[j]
j--//golang不支持nums2[j--]的写法
}
}
}
15. 三数之和
class Solution {
public List<List<Integer>> threeSum(int[] nums) {
List<List<Integer>> res = new ArrayList<>();//定义一个结果集 注意实例化没有两个list
int len = nums.length;//非length()
//当前数组的长度为空,或者长度小于3时,直接退出
if(nums == null || len <3){
return res;
}
Arrays.sort(nums);//将数组进行排序
for(int i = 0; i<len;i++){
//如果遍历的起始元素大于0,就直接退出
//原因,此时数组为有序的数组,最小的数都大于0了,三数之和肯定大于0
if(nums[i]>0){
break;
}
//去重,当起始的值等于前一个元素,那么得到的结果将会和前一次相同
if(i > 0 && nums[i] == nums[i-1]) continue;
int l = i +1;
int r = len-1;
//当 l 不等于 r时就继续遍历
while(l<r){
int sum = nums[i] + nums[l] + nums[r];
//如果等于0,将结果对应的索引位置的值加入结果集中
if(sum==0){
// 将三数的结果集加入到结果集中
res.add(Arrays.asList(nums[i], nums[l], nums[r]));
//在将左指针和右指针移动的时候,先对左右指针的值,进行判断
//如果重复,直接跳过。
//去重,因为 i 不变,当此时 l取的数的值与前一个数相同,所以不用在计算,直接跳
while(l < r && nums[l] == nums[l+1]) {
l++;
}
//去重,因为 i不变,当此时 r 取的数的值与前一个相同,所以不用在计算
while(l< r && nums[r] == nums[r-1]){
r--;
}
//将 左指针右移,将右指针左移。
l++;
r--;
}
//如果结果小于0,将左指针右移
else if(sum < 0){
l++;
}
//如果结果大于0,将右指针左移
else if(sum > 0){
r--;
}
}
}
return res;
}
}
回溯法
回溯算法与深度优先遍历
以下是维基百科中「回溯算法」和「深度优先遍历」的定义。
回溯法 采用试错的思想,它尝试分步的去解决一个问题。在分步解决问题的过程中,当它通过尝试发现现有的分步答案不能得到有效的正确的解答的时候,它将取消上一步甚至是上几步的计算,再通过其它的可能的分步解答再次尝试寻找问题的答案。回溯法通常用最简单的递归方法来实现,在反复重复上述的步骤后可能出现两种情况:
找到一个可能存在的正确的答案;
在尝试了所有可能的分步方法后宣告该问题没有答案。
深度优先搜索 算法(英语:Depth-First-Search,DFS)是一种用于遍历或搜索树或图的算法。这个算法会 尽可能深 的搜索树的分支。当结点 v 的所在边都己被探寻过,搜索将 回溯 到发现结点 v 的那条边的起始结点。这一过程一直进行到已发现从源结点可达的所有结点为止。如果还存在未被发现的结点,则选择其中一个作为源结点并重复以上过程,整个进程反复进行直到所有结点都被访问为止。
我刚开始学习「回溯算法」的时候觉得很抽象,一直不能理解为什么递归之后需要做和递归之前相同的逆向操作,在做了很多相关的问题以后,我发现其实「回溯算法」与「 深度优先遍历 」有着千丝万缕的联系。
个人理解
「回溯算法」与「深度优先遍历」都有「不撞南墙不回头」的意思。我个人的理解是:「回溯算法」强调了「深度优先遍历」思想的用途,用一个 不断变化 的变量,在尝试各种可能的过程中,搜索需要的结果。强调了 回退 操作对于搜索的合理性。而「深度优先遍历」强调一种遍历的思想,与之对应的遍历思想是「广度优先遍历」。至于广度优先遍历为什么没有成为强大的搜索算法,我们在题解后面会提。
在「力扣」第 51 题的题解《回溯算法(第 46 题 + 剪枝)》 中,展示了如何使用回溯算法搜索 444 皇后问题的一个解,相信对大家直观地理解「回溯算法」是有帮助。
作者:liweiwei1419
链接:https://leetcode.cn/problems/permutations/solutions/9914/hui-su-suan-fa-python-dai-ma-java-dai-ma-by-liweiw/
来源:力扣(LeetCode)
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
acktrack的公式:
result = []
def backtrack(路径, 选择列表):
if 满足结束条件:
result.add(路径)
return
for 选择 in 选择列表:
做选择
backtrack(路径, 选择列表)
撤销选择
46 全排列
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.List;
public class Solution {
public List<List<Integer>> permute(int[] nums) {
int len = nums.length;
// 使用一个动态数组保存所有可能的全排列
List<List<Integer>> res = new ArrayList<>();//这样实例化即可
if (len == 0) {
return res;//不能写[[]]
}
boolean[] used = new boolean[len];
Deque<Integer> path = new ArrayDeque<>(len);//可以指定长度
dfs(nums, len, 0, path, used, res);
return res;
}
private void dfs(int[] nums, int len, int depth,
Deque<Integer> path, boolean[] used,
List<List<Integer>> res) {
if (depth == len) {
res.add(new ArrayList<>(path));//不能add path
/*
在 Java 中,对象类型变量在传参的过程中,复制的是变量的地址。
变量path所指向的列表 在深度优先遍历的过程中只有一份,遍历完成以后,回到了根结点,成为空列表。
这些地址被添加到 res 变量,但实际上指向的是同一块内存地址,因此我们会看到 6 个空的列表对象。
解决的方法很简单,在 res.add(path); 这里做一次拷贝即可。
*/
return;
}
for (int i = 0; i < len; i++) {
//System.out.println("used数组 => " + Arrays.toString(used)+"当前的i为"+i + "是"+used[i]);
if (!used[i]) {
path.addLast(nums[i]);
used[i] = true;
/*
System.out.println("++++++++++++++++++++++++++++");
System.out.println("+++path递归之前 => " + path);
System.out.println("++++++++++++++++++++++++++++");
System.out.println("开始第"+(depth+2)+"层");
*/
dfs(nums, len, depth + 1, path, used, res);
used[i] = false;
path.removeLast();
//System.out.println(">>>>>>>>>>>>>>>>>>>>>>>>>>>>");
//System.out.println(">>>path递归之后(开始回溯) => " + path);
//System.out.println(">>>>>>>>>>>>>>>>>>>>>>>>>>>>");
}
}
}
/*
public static void main(String[] args) {
int[] nums = {1, 2, 3};
Solution solution = new Solution();
List<List<Integer>> lists = solution.permute(nums);
System.out.println(lists);
}
*/
}
打印结果如下(123为例)
used数组 => [false, false, false]当前的i为0是false
++++++++++++++++++++++++++++
+++path递归之前 => [1]
++++++++++++++++++++++++++++
开始第2层
used数组 => [true, false, false]当前的i为0是true
used数组 => [true, false, false]当前的i为1是false
++++++++++++++++++++++++++++
+++path递归之前 => [1, 2]
++++++++++++++++++++++++++++
开始第3层
used数组 => [true, true, false]当前的i为0是true
used数组 => [true, true, false]当前的i为1是true
used数组 => [true, true, false]当前的i为2是false
++++++++++++++++++++++++++++
+++path递归之前 => [1, 2, 3]
++++++++++++++++++++++++++++
开始第4层
>>>>>>>>>>>>>>>>>>>>>>>>>>>>
>>>path递归之后(开始回溯) => [1, 2]
>>>>>>>>>>>>>>>>>>>>>>>>>>>>
>>>>>>>>>>>>>>>>>>>>>>>>>>>>
>>>path递归之后(开始回溯) => [1]
>>>>>>>>>>>>>>>>>>>>>>>>>>>>
used数组 => [true, false, false]当前的i为2是false
++++++++++++++++++++++++++++
+++path递归之前 => [1, 3]
++++++++++++++++++++++++++++
开始第3层
used数组 => [true, false, true]当前的i为0是true
used数组 => [true, false, true]当前的i为1是false
++++++++++++++++++++++++++++
+++path递归之前 => [1, 3, 2]
++++++++++++++++++++++++++++
开始第4层
>>>>>>>>>>>>>>>>>>>>>>>>>>>>
>>>path递归之后(开始回溯) => [1, 3]
>>>>>>>>>>>>>>>>>>>>>>>>>>>>
used数组 => [true, false, true]当前的i为2是true
>>>>>>>>>>>>>>>>>>>>>>>>>>>>
>>>path递归之后(开始回溯) => [1]
>>>>>>>>>>>>>>>>>>>>>>>>>>>>
>>>>>>>>>>>>>>>>>>>>>>>>>>>>
>>>path递归之后(开始回溯) => []
>>>>>>>>>>>>>>>>>>>>>>>>>>>>
used数组 => [false, false, false]当前的i为1是false
++++++++++++++++++++++++++++
+++path递归之前 => [2]
++++++++++++++++++++++++++++
开始第2层
used数组 => [false, true, false]当前的i为0是false
++++++++++++++++++++++++++++
+++path递归之前 => [2, 1]
++++++++++++++++++++++++++++
开始第3层
used数组 => [true, true, false]当前的i为0是true
used数组 => [true, true, false]当前的i为1是true
used数组 => [true, true, false]当前的i为2是false
++++++++++++++++++++++++++++
+++path递归之前 => [2, 1, 3]
++++++++++++++++++++++++++++
开始第4层
>>>>>>>>>>>>>>>>>>>>>>>>>>>>
>>>path递归之后(开始回溯) => [2, 1]
>>>>>>>>>>>>>>>>>>>>>>>>>>>>
>>>>>>>>>>>>>>>>>>>>>>>>>>>>
>>>path递归之后(开始回溯) => [2]
>>>>>>>>>>>>>>>>>>>>>>>>>>>>
used数组 => [false, true, false]当前的i为1是true
used数组 => [false, true, false]当前的i为2是false
++++++++++++++++++++++++++++
+++path递归之前 => [2, 3]
++++++++++++++++++++++++++++
开始第3层
used数组 => [false, true, true]当前的i为0是false
++++++++++++++++++++++++++++
+++path递归之前 => [2, 3, 1]
++++++++++++++++++++++++++++
开始第4层
>>>>>>>>>>>>>>>>>>>>>>>>>>>>
>>>path递归之后(开始回溯) => [2, 3]
>>>>>>>>>>>>>>>>>>>>>>>>>>>>
used数组 => [false, true, true]当前的i为1是true
used数组 => [false, true, true]当前的i为2是true
>>>>>>>>>>>>>>>>>>>>>>>>>>>>
>>>path递归之后(开始回溯) => [2]
>>>>>>>>>>>>>>>>>>>>>>>>>>>>
>>>>>>>>>>>>>>>>>>>>>>>>>>>>
>>>path递归之后(开始回溯) => []
>>>>>>>>>>>>>>>>>>>>>>>>>>>>
used数组 => [false, false, false]当前的i为2是false
++++++++++++++++++++++++++++
+++path递归之前 => [3]
++++++++++++++++++++++++++++
开始第2层
used数组 => [false, false, true]当前的i为0是false
++++++++++++++++++++++++++++
+++path递归之前 => [3, 1]
++++++++++++++++++++++++++++
开始第3层
... 35 more lines
用公式更简洁的
class Solution {
public List<List<Integer>> permute(int[] nums) {
List<List<Integer>> res = new ArrayList<>();
List<Integer> list = new ArrayList<>();
backtrack(res, list, nums);
return res;
}
public void backtrack(List<List<Integer>> res, List<Integer> list, int[] nums) {
if(list.size() == nums.length) {
res.add(new ArrayList<Integer>(list));
return;
}
for(int num : nums) {
if(!list.contains(num)) {
list.add(num);
backtrack(res, list, nums);
list.remove(list.size() - 1);
}
}
}
}
滑动窗口法
3. 最长无重复子串
//最长无重复子串
class Solution {
public int lengthOfLongestSubstring(String s) {
// 记录字符(ASCII码形式)上一次出现的位置
int[] last = new int[128];
//默认初始化的话 之后会快一些!且和下面max函数的兼容 最小为0
for(int i = 0; i < 128; i++) {
last[i] = -1;
}
int n = s.length();//注意 字符串是带括号的方法
int res = 0;
int start = 0; // 窗口开始检索的位置
/*以这个字符串为例:abcabcbb,当i等于3时,也就是指向了第二个a,
此时我就需要查之前有没有出现过a, 如果出现了是在哪一个位置出现的。
然后通过last[index] (index是当前的字符的ascii码)查到等于0,
也就是说,如果start 依然等于0的话,那么当前窗口就有两个a了,也就是字符串重复了,
所以我们需要移动当前窗口的start指针,移动到什么地方呢?
移动到什么地方,窗口内就没有重复元素了呢?
对了,就是a上一次出现的位置的下一个位置,就是0 + 1 = 1。
当start == 1, 当前窗口就没有了重复元素,那么以当前字符为结尾的最长无重复子串就是bca,
然后再和之前的res取最大值。然后i指向后面的位置,按照同样思路计算。
*/
for(int i = 0; i < n; i++) {
int index = s.charAt(i);//会自动类型转换
//start代表开始检索的位置 若上次出现的位置和开始检索的位置相同 就代表重复 需要移动了 而移动的位置是i+1
start = Math.max(start, last[index] + 1);
res = Math.max(res, i - start + 1);
last[index] = i;
}
return res;
}
}
//这么写也对
class Solution {
public int lengthOfLongestSubstring(String s) {
int[] last_arr = new int[128];
int max = 0, left = 0;
for(int i=0 ;i<s.length();i++){
int index = s.charAt(i);
left = Math.max(left,last_arr[index]);
max = Math.max(max,i-left);
last_arr[index]=i;
}
return max;
}
}
//HashMap法
class Solution {
public int lengthOfLongestSubstring(String s) {
if (s.length()==0) return 0;
//hashmap中的 都是大写 要是对象才能放进去 而且前后都要申明类型
HashMap<Character, Integer> map = new HashMap<Character, Integer>();
int max = 0;
int left = 0;
for(int i = 0; i < s.length(); i ++){
if(map.containsKey(s.charAt(i))){//和之前有默认值的不同 这里要判断
left = Math.max(left,map.get(s.charAt(i)) + 1);
}
max = Math.max(max,i-left+1);
map.put(s.charAt(i),i);
}
return max;
}
}
面试题
非多题型类
过了一遍的
33. 搜索旋转数组
对于有序数组,可以使用二分查找的方法查找元素。
但是这道题中,数组本身不是有序的,进行旋转后只保证了数组的局部是有序的,这还能进行二分查找吗?答案是可以的。
可以发现的是,我们将数组从中间分开成左右两部分的时候,一定有一部分的数组是有序的。
class Solution {
public int search(int[] nums, int target) {
int n = nums.length;
if (n == 0) {
return -1;
}
if (n == 1) {
return nums[0] == target ? 0 : -1;
}
int l = 0, r = n - 1;
while (l <= r) {
int mid = (l + r) / 2;
if (nums[mid] == target) {
return mid;
}
if (nums[0] <= nums[mid]) {
if (nums[0] <= target && target < nums[mid]) {
r = mid - 1;
} else {
l = mid + 1;
}
} else {
if (nums[mid] < target && target <= nums[n - 1]) {
l = mid + 1;
} else {
r = mid - 1;
}
}
}
return -1;
}
}
121. 买卖股票最佳时期
显然,如果我们真的在买卖股票,我们肯定会想:如果我是在历史最低点买的股票就好了!太好了,在题目中,我们只要用一个变量记录一个历史最低价格 minprice,我们就可以假设自己的股票是在那天买的。那么我们在第 i 天卖出股票能得到的利润就是 prices[i] - minprice。
因此,我们只需要遍历价格数组一遍,记录历史最低点,然后在每一天考虑这么一个问题:如果我是在历史最低点买进的,那么我今天卖出能赚多少钱?当考虑完所有天数之时,我们就得到了最好的答案。
public class Solution {
public int maxProfit(int prices[]) {
int minprice = Integer.MAX_VALUE;
int maxprofit = 0;
for (int i = 0; i < prices.length; i++) {
if (prices[i] < minprice) {
minprice = prices[i];
} else if (prices[i] - minprice > maxprofit) {
maxprofit = prices[i] - minprice;
}
}
return maxprofit;
}
}
时间复杂度:O(n),只需要遍历一次。
空间复杂度:O(1),只使用了常数个变量。
146.LRU
力扣官方题解:
实现本题的两种操作,需要用到一个哈希表和一个双向链表。在面试中,面试官一般会期望读者能够自己实现一个简单的双向链表,而不是使用语言自带的、封装好的数据结构。在 Python 语言中,有一种结合了哈希表与双向链表的数据结构 OrderedDict,只需要短短的几行代码就可以完成本题。在 Java 语言中,同样有类似的数据结构 LinkedHashMap。这些做法都不会符合面试官的要求,因此下面只给出使用封装好的数据结构实现的代码,而不多做任何阐述。
我的分析
- 为了实现最少使用的删除,我们需要一个链表结构,而且,为了实现迅速删除末尾元素,如果使用单向链表想要找到最后一个节点,需要从头节点遍历过去,我们需要双端链表。(队列不适合 因为它只能先进先出)
- 为了实现迅速查找,我们需要一个hashmap结构,同时,为了迅速的进行移动操作,map的value需要是链表类型(这种类型 画图怎么表示呢?)
- 然后就是自己实现链表 需要定义几个函数:1是删除某个节点,2是将某节点添加到头部,3是删除尾部节点(复合函数)。4是将节点移动到头部(复合函数)
public class LRUCache {
//类中类
//注意巧妙之处 并不把虚拟头节点引入 且不在构造方法定义next和pre
//而且此处 是Node而非List
class DLinkedNode {
int key;
int value;
DLinkedNode prev;
DLinkedNode next;
public DLinkedNode() {}//定义虚拟结点的时候用到
public DLinkedNode(int _key, int _value) {key = _key; value = _value;}
}
private Map<Integer, DLinkedNode> cache = new HashMap<Integer, DLinkedNode>();
private int size;//size超出capacity要删除
private int capacity;//注意private属性保护变量
private DLinkedNode head, tail;//可以一起写
public LRUCache(int capacity) {
this.size = 0;
this.capacity = capacity;
// 使用伪头部和伪尾部节点
head = new DLinkedNode();//为什么有的定义的时候实例化 有的用到才实例化?
tail = new DLinkedNode();
head.next = tail;
tail.prev = head;
}
public int get(int key) {
DLinkedNode node = cache.get(key);
if (node == null) {
return -1;
}
// 如果 key 存在,先通过哈希表定位,再移到头部
moveToHead(node);
return node.value;
}
public void put(int key, int value) {
DLinkedNode node = cache.get(key);
if (node == null) {
// 如果 key 不存在,创建一个新的节点
DLinkedNode newNode = new DLinkedNode(key, value);
// 添加进哈希表
cache.put(key, newNode);
// 添加至双向链表的头部
addToHead(newNode);
++size;
if (size > capacity) {
// 如果超出容量,删除双向链表的尾部节点
DLinkedNode tail = removeTail();
// 删除哈希表中对应的项
cache.remove(tail.key);
--size;
}
}
else {
// 如果 key 存在,先通过哈希表定位,再修改 value,并移到头部
node.value = value;
moveToHead(node);
}
}
private void addToHead(DLinkedNode node) {
node.prev = head;
node.next = head.next;
head.next.prev = node;
head.next = node;
}
private void removeNode(DLinkedNode node) {
node.prev.next = node.next;
node.next.prev = node.prev;
}
private void moveToHead(DLinkedNode node) {
removeNode(node);
addToHead(node);
}
private DLinkedNode removeTail() {
DLinkedNode res = tail.prev;
removeNode(res);
return res;
}
}
25. K个一组
class Solution {
public ListNode reverseKGroup(ListNode head, int k) {
ListNode dummy = new ListNode(-1, head);//定义一个虚拟头结点
ListNode start_pre = dummy;//start_pre是每次循环的第一个节点的前一个 也可理解为上次循环的最后一个
while (true) {
// 检查剩余节点是否有k个,不足则返回
ListNode end = start_pre;
for (int i = 0; i < k; i++) {
end = end.next;
if (end == null) {
return dummy.next;
}
}
// 翻转k个节点 头插法
ListNode curr = start_pre.next, next;//当前循环到的、当前循环到的下一个
for (int i = 0; i < k - 1; i++) {
next = curr.next;//保存下状态
curr.next = next.next;
next.next = start_pre.next;
start_pre.next = next;
}
start_pre = curr;
}
}
}
两个单链表相交的起始节点
//两个单链表相交的起始节点
/**
\* 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 pA = headA, pB = headB;
// 在这里第一轮体现在pA和pB第一次到达尾部会移向另一链表的表头, 而第二轮体现在如果pA或pB相交就返回交点, 不相交最后就是null==null
while(pA != pB) {
pA = (pA == null ? headB : pA.next);
pB = (pB == null ? headA : pB.next);
}
return pA;
}
}
分组链表反转
//分组链表反转 递归代码并不简洁 放弃
/**
\* 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 {
//思路:遍历法:先对组内的链表反转 后面连接各个数组 所以两个for循环
public ListNode reverseKGroup(ListNode head, int k) {
//dummyHead和pre由于后面没有等号复制 要写成new的形式
//dummyHead是虚拟的初始头结点 不用的话返回有点麻烦
//pre和next用于保存结点前后的信息 用于反转
//link用于记录两个组连接的前面那个结点 gHead 是group小组的初始头结点
ListNode dummyHead= new ListNode(),pre= new ListNode(),next,gHead,link ;
dummyHead.next = head;
//求链表的长度 while内的不是形参 不需要引用
int length = 0;
while(head!=null){
length++;
head = head.next;
}
head = dummyHead.next;
//虚拟头结点连接反转后的第一个组
link = dummyHead;
//处理完整的组
for(int i=0;i<length/k;i++){
gHead = head;
//反转组内链表
for(int j=0;j<k;j++){
next = head.next;
head.next = pre;
pre =head;
head = next;
}
//连接操作
link.next = pre;
link = gHead;
//清除pre 用于下一次循环
pre = null;
}
//如果最后一个组数量不够 若数量够 此时的head为null
link.next = head;
return dummyHead.next;
}
}
删除链表重复元素
//删除链表重复元素
/**
* 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 static ListNode deleteDuplicates(ListNode head) {
//1写退出条件 如果head为null直接退出 如果最后递归结束 head.next为null也结束递归
if(head==null||head.next==null){
return head;
}
//2 明确我们的返回值是什么 此处应该返回最开始的head头结点 所以 为了记录下后来的状态 应该定义一个next去进行之后的操作
ListNode next = head.next;
//3 递归的逻辑开始 分两种情况 A 1-2-2-3 这种较简单 每次递归后面的结点即可
//1-1-1-2-3的形式 这种不仅要 B1 向后next 还要 更改头结点为第一个不重复的结点
//由于头结点的特殊性 应该吧B放到前面处理
if(head.val==head.next.val){
//B1
while(head.next!=null && head.val == head.next.val){
head.next =head.next.next;
}
//B2 更改头结点 此处由于是第一个的位置 可以递归 (向后压栈 到等于返回的第一个结点)
head = deleteDuplicates(head.next);
}else{
//如果是正常情况 那么只用迭代后面的即可
head.next =deleteDuplicates(head.next);
}
return head;
}
}
//迭代法
class Solution {
public ListNode deleteDuplicates(ListNode head) {
ListNode dummy = new ListNode(0, head);
ListNode prev = dummy, curr, next;
while ((curr = prev.next) != null && (next = curr.next) != null) {
if (next.val != curr.val) {
prev = prev.next;
continue;
}
while (next != null && next.val == curr.val) next = next.next;
prev.next = next;
}
return dummy.next;
}
}
求平方根
//求平方根 二分查找,用x/m<m而不是m*m>x防止溢出
int mySqrt(int x)
{
if(x == 1)
return 1;
int min = 0;
int max = x;
while(max-min>1)
{
int m = (max+min)/2;
if(x/m<m)
max = m;
else
min = m;
}
return min;
}
两数之和的三种解法
1.暴力双重遍历,O(n²),61ms
class Solution {
public int[] twoSum(int[] nums, int target) {
for(int i=0;i<nums.length-1;i++)
for(int j=i+1;j<nums.length;j++)
if(nums[i]+nums[j]==target)
return new int[] {i,j};
return new int[] {};
}
}
2.排序,然后转换为缩小窗口的问题,O(nlogn),4ms
class Solution {
public int[] twoSum(int[] nums, int target) {
//为了返回在原数组中的位置 需要复制一个 此处用copy的原因是 直接相等是引用
//加括号用于字符串的长度 不加括号是数组的
int[]nums2= Arrays.copyOf(nums,nums.length);
Arrays.sort(nums);
int i=0,j=nums.length-1;
//找到i和j
while(nums[i]+nums[j]!=target) {
//若小于 滑动
if(nums[i]+nums[j]<target)
i++;
else if(nums[i]+nums[j]>target)
j--;
}
//找到原数组中的位置
int r1=0,r2=0;
for(int k=0;k<nums2.length;k++)
if(nums2[k]==nums[i]) {
r1=k;
break;
}
//从末尾开始循环 用于找不一样的
for(int k=nums2.length-1;k>=0;k--)
if(nums2[k]==nums[j]) {
r2=k;
break;
}
return new int[] {r1,r2};
}
}
3.哈希表存储访问过的元素,每访问一次,判断哈希表是否有与其匹配的存储,O(n),2ms
class Solution {
public int[] twoSum(int[] nums, int target) {
Map<Integer, Integer>map = new HashMap<>();//元素值和元素位置的映射
map.put(nums[0], 0);
//注意这里从1开始而非0
for(int i=1;i<nums.length;i++) {
int matchedNum=target-nums[i];//nums[i]和matchedNum的索引即为所求
if(map.containsKey(matchedNum)) {//map存储过matchedNum的映射
int index1=i;
int index2=map.get(matchedNum);
return new int[] {index1,index2};
}else//map未存储过
map.put(nums[i], i);
}
return null;
}
}
链表有环否
提供一个全新的思路,一次遍历单指针搞定,时间击败100%。每次遍历完一个节点,将它的下一个节点指向初始节点,然后继续遍历: 如果下一节点为空,没有换 如果下一节点的下一指针为root,有环。
public class Solution {
public boolean hasCycle(ListNode head) {
ListNode root = head;
while(head!=null){
if(head.next==root) return true;//如果节点的下一节点为初始节点 有环
ListNode tem = head;
head = head.next;//否则继续遍历下一个节点
tem.next = root;//上一个节点的下一节点为初始节点
}
return false;//走到了尽头,没有换
}
}
有效的括号
//有效的括号
class Solution {
public boolean isValid(String s) {
//s是大写 C也是大写
Stack<Character> stack =new Stack<Character>();
for(char c :s.toCharArray()){
if(c=='(')stack.push(')');
else if(c=='[')stack.push(']');
else if(c=='{')stack.push('}');
//如果栈为空 代表s为空 直接返回false 或者当c是反括号的时候 ··
else if(stack.isEmpty()||c!=stack.pop())return false;
}
return stack.isEmpty();
}
}
合并有序数组
//合并两个有序数组
class Solution {
public void merge(int[] nums1, int m, int[] nums2, int n) {
//为何此处不直接等于m+n-1? 因为m--表示使用后-1!--n表示使用前-1 使用完后其值也要改变
int i = m--+--n;
while(n>=0) {
nums1[i--] = m>=0 && nums1[m]>nums2[n] ? nums1[m--] : nums2[n--];
}
}
}
二叉树的最近公共祖先
/**
\* Definition for a binary tree node.
\* public class TreeNode {
\* int val;
\* TreeNode left;
\* TreeNode right;
\* TreeNode(int x) { val = x; }
\* }
*/
class Solution {
public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) {
if(root==null||root==p||root==q) return root;
TreeNode left = lowestCommonAncestor(root.left,p,q);
TreeNode right = lowestCommonAncestor(root.right,p,q);
if(left!=null && right!=null) return root;
else if(left!=null) return left;
else if(right!=null) return right;
return null;
}
}
环形链表
方法一:哈希表
思路及算法
最容易想到的方法是遍历所有节点,每次遍历到一个节点时,判断该节点此前是否被访问过。
具体地,我们可以使用哈希表来存储所有已经访问过的节点。每次我们到达一个节点,如果该节点已经存在于哈希表中,则说明该链表是环形链表,否则就将该节点加入哈希表中。重复这一过程,直到我们遍历完整个链表即可。
public class Solution {
public boolean hasCycle(ListNode head) {
Set<ListNode> seen = new HashSet<ListNode>();
while (head != null) {
if (!seen.add(head)) {
return true;
}
head = head.next;
}
return false;
}
}
复杂度分析
时间复杂度:O(N)O(N)O(N),其中 NNN 是链表中的节点数。最坏情况下我们需要遍历每个节点一次。
空间复杂度:O(N)O(N)O(N),其中 NNN 是链表中的节点数。主要为哈希表的开销,最坏情况下我们需要将每个节点插入到哈希表中一次。
方法二:快慢指针
思路及算法
本方法需要读者对「Floyd 判圈算法」(又称龟兔赛跑算法)有所了解。
假想「乌龟」和「兔子」在链表上移动,「兔子」跑得快,「乌龟」跑得慢。当「乌龟」和「兔子」从链表上的同一个节点开始移动时,如果该链表中没有环,那么「兔子」将一直处于「乌龟」的前方;如果该链表中有环,那么「兔子」会先于「乌龟」进入环,并且一直在环内移动。等到「乌龟」进入环时,由于「兔子」的速度快,它一定会在某个时刻与乌龟相遇,即套了「乌龟」若干圈。
我们可以根据上述思路来解决本题。具体地,我们定义两个指针,一快一慢。慢指针每次只移动一步,而快指针每次移动两步。初始时,慢指针在位置 head,而快指针在位置 head.next。这样一来,如果在移动的过程中,快指针反过来追上慢指针,就说明该链表为环形链表。否则快指针将到达链表尾部,该链表不为环形链表。
细节
为什么我们要规定初始时慢指针在位置 head,快指针在位置 head.next,而不是两个指针都在位置 head(即与「乌龟」和「兔子」中的叙述相同)?
观察下面的代码,我们使用的是 while 循环,循环条件先于循环体。由于循环条件一定是判断快慢指针是否重合,如果我们将两个指针初始都置于 head,那么 while 循环就不会执行。因此,我们可以假想一个在 head 之前的虚拟节点,慢指针从虚拟节点移动一步到达 head,快指针从虚拟节点移动两步到达 head.next,这样我们就可以使用 while 循环了。
当然,我们也可以使用 do-while 循环。此时,我们就可以把快慢指针的初始值都置为 head。
public class Solution {
public boolean hasCycle(ListNode head) {
if (head == null || head.next == null) {
return false;
}
ListNode slow = head;
ListNode fast = head.next;
while (slow != fast) {
if (fast == null || fast.next == null) {
return false;
}
slow = slow.next;
fast = fast.next.next;
}
return true;
}
}
解法3
歪路就是构造一个假节点,把所有访问过的表格的next都指向假节点,并且在循环内判断是否访问到构造的假节点。
public class Solution {
public boolean hasCycle(ListNode head) {
return check(head);
}
public boolean check(ListNode node){
if(node==null||node.next==null)return false;
node.val=Integer.MAX_VALUE;//因为-105 <= Node.val <= 105
if(node.next.val==Integer.MAX_VALUE)return true;
return check(node.next);
}
}
查找
二分查找
class Solution {
public int 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) {
return mid;
} else if(nums[mid] > target) {
right = mid - 1;//注意要减1
} else {
left = mid + 1;//注意要加1
}
}
return -1;
}
}
有趣的评论
“程乙己,你又没有思路了!”他不回答,只是将题解ctrl+c再ctrl+v到编辑器。他们又故意的高声嚷道,“你一定又写不出代码了了!”程乙己睁大眼睛说,“你怎么这样凭空污人清白……”“什么清白?我前天亲眼见你copy了别人的整段代码,吊着打。”程乙己便涨红了脸,额上的青筋条条绽出,争辩道,“copy代码不能算copy……抄!……程序员的事,能算抄么?”接连便是难懂的话,什么“拓展思路”,什么“借鉴”之类,引得众人都哄笑起来:评论区内外充满了快活的空气。
70爬楼梯: “题解小姐姐声音太酥了吧”