本仓库内包含了java实现的leetcode解法,代码规范,可读性良好,其中的解法思想并不受语言限制。
bfs能解决什么样的问题
图遍历中是否可达、最短路径等等。
一个boolean[] visited数组,记录访问过的位置 两个List,保存要遍历的节点和下次要遍历的节点,Node的值可以按照情况变化,比如可以是数组的index,也可以是一个Point(x,y)对象等 注意这里的List在某些要去重的场景下需要使用Set代替
// 创建需要的数据结构
boolean[] visited = new boolean[length];
List<Integer> prev = new ArrayList();
List<Integer> next = new ArrayList();
// 初始化prev元素
prev.add(firstNode)
while (!prev.isEmpty() {
for (int nodeIndex : prev) {
// 根据具体的问题对每个node做处理
doSomething(nodeIndex)
// 记录下这个node已经访问过了
visited[nodeIndex] = true;
// 对从当前node可以直达的下一个node,如果没有访问过,加到next中
for (adjacentNodeIndex in getAdjacentList(nodeIndex)node.adjacentList) {
if (!visited[adjacentNodeIndex]) {
next.add(adjacentNodeIndex)
}
}
}
// 交换prev next
prev = next;
next = new ArrayList();
}
很多树的问题可以用层级遍历解决,树的广度优先搜索就是层级遍历,下面找一些题目举例
判断一个二叉树是否是对称的,对称的特点是以中心为镜像,把树的每一层想象成一个列表,问题即为树的每一层列表是不是对称的, 通过上面的层次遍历,能够轻松的构建出每一层的列表,而判断列表是不是对称的,也很好实现。
这个有点直接了,直接按照上面的bfs框架,就能获得每一层的列表
也是层次遍历,对于锯齿,只需要记录一个boolean值,初始false,每遍历一层切换true/false一次,如果是true则遍历时把列表翻转一下。
自底向上遍历,层次遍历后把最终顺序翻转一下就可以
即最高的叶子节点,叶子节点就是子节点都为空的节点,通过层次遍历,并记录层次数,如果遍历到某一层时某个节点的子节点都是空的,则返回当前遍历的层次即可。
N叉数和二叉树区别不大,最大深度也是高度,通过层次遍历记录高度即可。
二叉树的右视图,即为层次遍历的每个列表的最后一个元素
层次遍历到最后一层的和
层次遍历,每次遍历覆盖记录每一层第一个值,遍历完返回即可。
层次遍历,每层找最大值
从二叉树中某一个节点开始距离为K的节点,通过parent的引用线上也算合法路径。这个问题可以把二叉树转换成一个图(图结构中每个节点有一个邻接链表), 然后从起始节点开始bfs,宽度优先向外遍历k次即可。
Count Good Nodes in Binary Tree
从根节点开始,记录一个总数,bfs每次遍历到节点计数+1,如果子节点大于当前节点,则加入到下次遍历。 这个问题也可以通过dfs来解决。可以查看代码中具体的实现
Time Needed to Inform All Employees
树的层次遍历还是比较简单的,当然实际面试过程中的题目可能不会这么直接,有可能会故意用一些故事、背景包装,希望大家能够透过问题看到本质。
例如 Target Sum
这个题目可以使用暴力算法或加上dp缓存实现。这里提供一个bfs的解法,对于每一个元素的加减,想象成一个树状结构的bfs遍历,遍历的内容是之前的所有加和的结果集, 从上一层的结果集遍历对每个元素加减下一个数组元素得到下一层的结果集,由于可能有重复的加和,所以要保存一个计数。最终寻找结果集的值和数量即为结果。 题目这里限制了原始数组非负数且总和在一定范围内,所以可以用数组提高效率。参考TargetSum.java
Number of Good Leaf Nodes Pairs
一种比较直观的解题思路为首先根据二叉树构造成一个图,即把子节点和父节点的关系也构建出来。标记所有的叶子节点,然后从每个叶子节点bfs遍历k次看能达到的其他 子节点的数量。
01二维数组问题,通常题目背景是岛屿海洋等背景,在一个二维数组里,每个值可以是0或者1,二维数组之间的又有相连的关系等。
举例
计算每个位置到最近的0节点的距离。从所有的0节点开始bfs遍历,遍历到一个值为1的节点时,当前的步数为从起始0到当前1节点的最短距离, 每个节点保存一个到最近的0节点的最小值,如果当前最短距离比之前最小值小则更新最小值。
给定一个数组arr和起始位置start,每次在index为i的位置时,能够 跳到i + arr[i]或i - arr[i]的位置(不能跳出数组),问能 否跳到一个值为零的位置 使用bfs遍历就可以完成,一直遍历(不走已经跳过的位置),直到调到一个0的位置或者没有位置可跳
比jump game3更复杂一些,引入了相同值也能跳的规则,实现上也是类似的bfs方式。不过为了节省空间等不再每次使用两个List而是 使用一个queue。并且做了一些map、是否访问过等优化。
Get Watched Videos by Your Friends
获取第N层好友的观看列表,关键在于怎么获得第N层好友,使用bfs即可实现,注意每一层的bfs要使用Set保存,因为可能有重复数据。
从1个单词转换到另一个单词,求最短转换次数和路径,使用bfs思路实现。
dfs常见写法框架
注意有的场景只需要标记是否访问过就可以,有的场景可能需要三色标记。 有的时候dfs需要和dp结合一下,或者直白一点说是加一个缓存(Map或数组存储之前计算过的结果)来减少计算。
int dfs(Graph graph, boolean[] visited, int index) {
visited[index] = true;
int[] adjacent = graph.adjacent(index);
for (int adj : adjacent) {
if (!visited[]) {
// 递归处理货拿到dfs的结果,有的题目会要求汇总所有的节点的结果
dfs(graph, visited, adj);
}
}
// 对本节点执行某些操作,有可能会依赖从这个节点直接能够达到的邻接节点的结果
doSomething(graph, index);
}
经常出现的类型是二维int数组,0表示水,1表示陆地,陆地或水域区域周围(有时是上下左右4个方向有时是包括斜对角的8个方向)可以相连。 基于这种场景,可以询问最大区域、区域个数等问题。 01二维数组问题通常既可以用dfs也可以用bfs思路解决。
01二维数组问题常见写法之dfs,参考NumberOfClosedIslands类 首先判断输入合法性,对一些边界case进行处理。 然后初始化需要的一些辅助数据,例如visited数组,需要注意这里如果visited是二维数组,需要提前把二维数组的一维数组部分初始化好。 然后对每个位置,判断位置的值、是否访问过,如果符合条件,进行dfs处理。 dfs函数中,先标记这个位置已经访问过。然后创建一个表示邻接方向的二维数组{{0,1}{0,-1},{-1,0},{1,0}}, 对这个二维数组表示的每个方向判断是否在数组范围内、是否访问过等条件,满足条件继续dfs处理。
public int closedIsland(int[][] grid) {
if (grid == null || grid.length == 0 || grid[0].length == 0) {
return 0;
}
int islandNumber = 0;
int notClosedNumber = 0;
int[][] islandNumberMark = new int[grid.length][];
for (int i = 0; i < grid.length; i++) {
islandNumberMark[i] = new int[grid[i].length];
}
for (int i = 0; i < grid.length; i++) {
for (int j = 0; j < grid[i].length; j++) {
// island and not visited
if (grid[i][j] == 0 && islandNumberMark[i][j] == 0) {
boolean notClose = dfsMark(grid, islandNumberMark, i, j, ++islandNumber);
if (notClose) {
notClosedNumber++;
}
}
}
}
return islandNumber - notClosedNumber;
}
/**
* true 不是close
* false 还不确定
*/
boolean dfsMark(int[][] grid, int[][] islandNumber, int row, int col, int number) {
islandNumber[row][col] = number;
boolean notClosed = 0 == row || row == grid.length - 1 || 0 == col || col == grid[row].length - 1;
int[][] directions = new int[][] {
{-1, 0},
{1, 0},
{0, -1},
{0, 1},
};
for (int[] direction : directions) {
int xDiff = direction[0];
int yDiff = direction[1];
int newRow = row + yDiff;
int newCol = col + xDiff;
if (newRow >= 0 && newRow < grid.length && newCol >= 0 && newCol < grid[row].length) {
if (grid[newRow][newCol] == 0 && islandNumber[newRow][newCol] == 0) {
notClosed = dfsMark(grid, islandNumber, newRow, newCol, number) || notClosed;
}
}
}
return notClosed;
}
举例
记录一个island总数,同时也作为island的编号生成器、非封闭的island总数,创建一个二维数组标记island属于哪个island编号, 如果遇到值为0的位置,则从这个位置开始dfs遍历,标记dfs遇到的标记数组位置为island编号,如果dfs过程中任何一个点位于边上,则非封闭数量加1,最终封闭 island数量即为island总数减去非封闭island数量。
相当于上面问题的简化版,区别是只需要计算island总数就可以了。
每次dfs时统计遍历区域的和,最终计算一个最大值。
统计不能抵达边界的island的面积的和,和统计closed island问题类似。也有另一个解法,把所有的边界的值通过dfs修改二维数组的值为-1, 然后再遍历统计值为1的数量。
使用三色标记法,没有访问过的节点为白色,正在dfs过程中的节点标记位灰色,如果一个节点dfs处理完,标记位黑色。 如果在dfs过程中遇到了一个灰色的节点,则说明出现了环
dfs的另一个应用是拓扑排序,举一个常见例子,大学生安排课程,某些课程需要在其他课程学习完之后才能学习,因此就形成了一个依赖关系图。 把依赖关系当成边,对图进行dfs遍历,递归过程中,执行doSomething时把当前节点加入到结果列表中,最终的列表顺序就是拓扑排序顺序。 由于可能出现循环依赖,因此需要使用判断图中是否存在环的方式检查是否有循环依赖。
拓扑排序的其他使用场景还有
- 任务编排
- maven编译
leetcode上的问题有
这个题目只需要判断是否有循环依赖。对数组的每个元素进行dfs(也要判断是否访问过了),如果dfs中遇到了一个正在遍历的节点(灰色节点),说明有循环依赖。
https://leetcode.com/problems/course-schedule-ii/
准备一个List,doSomething时把节点放到List中,如果有循环依赖,返回空列表。
查询是否是一个课程的深度依赖,由于查询比较多,所以要提前建立好所有节点的深度依赖(直接依赖和直接依赖的深度依赖,如果没有依赖,返回空)。 通过dfs查找构建所有节点的深度依赖,最后遍历查询即可。
一些树的问题,每个节点的值依赖所有子节点或者父节点的值,则可以用dfs解决
Number of Nodes in the Sub-Tree With the Same Label
计算子树中字母和当前节点相同的数量。一种解决思路为保存每个字母的计数,在dfs遍历到每个节点时,记录一个当前字母的计数,记录完计数加一,然后遍历完子节点后看一下这个字母的计数, 减去当前值即为当前这个节点的结果值。另外这个问题需要提前构造好树结构,因为参数中提供的edge是无向边。
最短路径有bfs(无权重)、dijkstra(非负权重)、topology(有向无环图可以有负权重)、Bellman-Ford(能够解决任何权重图且没有负向环的最短路径问题)集中算法实现
[Path with Maximum Probability](https://leetcode.com/problems/path-with-maximum-probability/
最大概率路径问题,首先根据edges和prob构建图,以及对应的边的概率prob。从start开始遍历图,用一个Map<Integer, Double>记录所有已经遍历过的位置和对应的概率prob,如果遇到已经遍历过的节点并且当前的 路径的概率比之前大则替换,如果没有遇到过放到map中,否则说明没有可遍历的了,结束。 优化版本,使用队列保存待遍历的节点(类似bfs思想),对队列里取出的每个节点,判断能否发现新边或更大概率的边,如果可以放到队列继续。
用于查询两个节点是否能互相连通 并查集通过记录一个count值,每次dfs将互相能连通的节点的id设置成相同的count值,并把count++,进行下一轮dfs,最后判断两个节点的id是否相同来判断是否能连通
并查集的模板实现
public class UnionFind {
private int[] parent;
private int[] size;
private int componentCount = 0;
public UnionFind(int n) {
this.parent = new int[n];
this.size = new int[n];
for (int i = 0; i < n; i++) {
parent[i] = i;
size[i] = 1;
}
componentCount = n;
}
public boolean findAndUnion(int x, int y) {
int parentX = getParent(x);
int parentY = getParent(y);
if (parentX == parentY) {
return false;
}
union(x, y);
return true;
}
public void union(int x, int y) {
int parentX = getParent(x);
int parentY = getParent(y);
if (parentX == parentY) {
return;
}
int sizeX = size[parentX];
int sizeY = size[parentY];
if (sizeX < sizeY) {
parent[parentX] = parentY;
size[parentY] += size[parentX];
} else {
parent[parentY] = parentX;
size[parentX] += size[parentY];
}
componentCount--;
}
public int getParent(int i) {
if (parent[i] == i) {
return i;
}
parent[i] = getParent(parent[i]);
return parent[i];
}
public boolean isConnected(int x, int y) {
return getParent(x) == getParent(y);
}
public int count() {
return componentCount;
}
}
并查集能够解决的题目有 Remove Max Number of Edges to Keep Graph Fully Traversable 首先对类型为3的路径的节点加入到并查集中,加入的过程中需要判断两个点是否已经属于一个集合,如果属于则说明这个边不需要添加。 处理完类型为3的边后依次添加Alice能够使用的边的节点,如果处理完Alice的边,最终的集合数量大于1,则说明不能联通,返回-1. 对于Bob也做类似处理。最终如果都能联通,则使用最开始的边,减去并查集中加入的边的数量。
Detect Cycles in 2D Grid 这个题目可以使用dfs解决,也可以使用并查集解决,
Kosaraju's algorithm: 先计算G reverse的reversePost order,然后对G按照这个order对没有遍历过的节点进行dfs,每一轮dfs遍历到的节点之间是强连通的
dynamic programming中文大家都称为动态规划,这个中文翻译名字确实很差,从名字上根本看不出是什么含义。 这里放出我的理解,动态规划就是递归加上缓存。
动态规划问题示例
若使用暴力破解,则复杂度为2的阶乘,复杂度过高。这种题目一般先用公式表示这一步和附近几步之间的关系。 例如我们用f(i,j)表示第i行第j列需要多少血量,a(i,j)表示数组的值,在不考虑边界的情况下f(i,j) = max(1, min((f(i+1,j) - a(i,j)), (f(i,j+1) - a(i,j)))) 然后递归终止在f(rowLength-1, colLength-1),值为Math.max(1, 1 - a(rowLength-1, colLength-1))。 在下边界和有边界的公式也能类似推导出来。 有了公式之后,可以发现依赖函数的最小值选择不影响当前函数的选择,增加一个二维数组作为缓存来避免重复计算,即完成了dp(动态规划)过程。
双指针的左右指针,指数组左右两个指针,不断互相逼近查找目标值 比较好理解的是Two Sum II - Input array is sorted
因为数组已经排好序,分别设置左指针指向0,右指针指向数组最后一个元素,比较两个指针的值的和,如果比目标值小,只能向右移动左指针,因为向左移动右指针只会比当前值更小。 反之如果比目标和大了,需要向左移动右指针。
从最左边开始,两个指针每轮更新,left = right + 1, right = max(last range step),如果某一次遍历right没有更新,说明不能jump到
在Jump Game基础上增加了一个计数
常用于在数组内移动一些元素,并保持其他数组不变 Remove Element
先二分找到对应的行,再二分找对应的列
Evaluate Reverse Polish Notation
模拟实现Java的操作数栈即可,保存一个int的栈,然后对每个操作符设置对应的操作方法。
本人计算机编程是自学的,并非所谓的"计算机科班出身",最开始算法方面的学习较少,因为在工作中(本人服务端Java开发)确实不会直接用到。 平时的各类产品需求,编写的代码真的非常简单,读参数、调服务、转换返回数据等等,没什么难度。 但是在面试过程中,又会遇到大量的算法问题。 曾经我去面试的时候,自认为对Java语音、虚拟机、常用的框架等的原理都掌握的很深入了,出去面试拿offer应该如同探囊取物。 然而事实向我泼了一盆冷水,很多面试官问了我自认为是脑筋急转弯的问题,在面试现场怎么想也想不出来。
为什么面试中,会问这么多算法问题呢。 这里的原因会比较多,我自己思考的一些原因有。
- 算法题是考察代码编写能力的一个重要方式。大部分软件工程师的一个重要工作是编写代码,代码质量、熟练度该如何考核呢,面试线上又不能找个需求让面试者开发实现, 因此算法题就成了一个比较合适的考察方式,解题中用到的数据结构使用、实现方案设计、代码风格等都能看出候选人的代码编写能力。
- 算法题能够考察候选人的问题解决能力等,大部分公司都希望能够招聘到能力强的候选人,而能力强在互联网工程师工作中一般指解决问题能力强,算法问题千变万化, 面试过程中如何分析、思考、解决问题,也是评估候选人问题解决能力的一个重要参考。
- 更加严格的筛选方式。虽然平时的工作,其实大部分人都能完成,但是完成的速度、质量等就不一定了,所以公司还是希望能够招聘到更优秀的人才。而面试考察的一些其他知识, 比如语言特性、框架知识点等,网上有各种各样的面试宝典,很多候选人都会突击背诵。举一个例子,比如Java语音的垃圾回收机制,如果问太简单,大部分人都了解或者背诵过。 如果问的太难,要么是我也不会,要么是其实这个岗位根本不需要这种知识。因此很多软知识点不方便明确区分候选人。而算法问题则更加多变,不是只靠背题就能解决的。
每个公司、面试官一般都有一些特定的算法题(大部分都是网上的题目比如leetcode上的,很大一部分面试官算法水平也不怎么样,还没有达到出题水平)
算法问题如何解决
曾几何时,在没有更加系统得学习算法之前,我解决算法题都是靠蛮力,闷头想,简单一点的还能想出来,稍微复杂的问题变完全无处下手。 更形象的描述就是像小学生做初中题目、初中生做高中题目,比如没有学过球体积的计算公式的情况下求球的体积,如果没有学过对应的"公式",解起题来困难相当大。
后来通过学习一些算法书和练习后,开始有了一些体会,做算法题就应该像做数学题一样,先学习常见的数据结构和算法,这些内容学习后变成了自己的工具。 在解题时,要先尝试分类,看这个题的考点是什么,能够用什么样的工具(算法、数据结构)解决。 有了这样的思考后,解决算法题目就更加简单了。 平时把算法问题分门别类,并进行专项学习练习。 在面试时,对于问题进行分析判断,剥开问题的伪装,内部就是要考察的具体算法类型了,通过常见的算法框架一套,就能轻松解决。 如果更复杂一点的情况,可能是组合多个算法。
命名要合理、代码的style要规范
注意判断空(参数、map.get后结果)等,注意数组越界问题
- 写代码前把思路想清楚,思路确定差不多后和面试官沟通确认,并且要思路这种解法的时间空间复杂度是多少,预估耗时,思考是否有更优解法。
- 注意检查题目,写完代码仔细检查,考虑边界case,避免出现低级错误,写完从头到尾用小黄鸭调试法讲解一下实现过程,要假设自己的代码有错误,尝试查找错误。
- 编码注意可读性,有时经常看到一些解法使用到比较trick的技巧例如复用一些内存数据,虽然能够带来一点点效率提升,但是我认为并不直观,毕竟面试时还是需要让人看懂比较重要。
其他常见低级错误
- 数组越界
- 没判断空,例如二维数组没有初始化一维数组
- 复制错变量,比如有两段类似的代码,一个处理左节点一个处理右节点,则可能会使用错变量
- 循环从大到小循环时,i--写成i++