EDA前端面试算法题目总结,这里主要包括了前端面试中常见的算法题及对应解析,希望对你有所帮助
1. 单源最短路径
- 单源最短路径,旨寻找图中(由结点和路径组成的)两个结点之间最短的路径
即,给定带权有向图G=(V,E),其中每条边的权E是非负实数,V中的一个结点称之为源
算法要计算从源到其他各结点V的最短路径的长度(到达路径各边权值之和)
- Floyd(弗洛伊德)算法 —— 插点法
使用动态规划的思想,得到最终路径R,其中包括了所有点之间的最短距离,需要使用到邻接矩阵
可以计算正、负权边E
算法:
(
)初始化(将矩阵的值全初始化),并随机选择一个
作为根结点
(
)动态规划,选择距离
最近的结点
,并将
加入到结果R中
(
)重复步骤(
),但选择距离R最近的节点,并加入到结果中
(
)重复(
-
),共n-1次
本质就是暴力枚举法,对数据少的情况效果好
void Floyd(int map[][]) //邻接矩阵,读入信息G(V,E)
{
for (int k = 1; k <= n; k++) { // Floyd算法模板
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= n; j++) {
if (mapp[i][k] + mapp[k][j] < mapp[i][j])
mapp[i][j] = mapp[i][k] + mapp[k][j];
}
}
}
}
- Dijkstra(迪杰斯特拉)算法
使用贪心算法,设置单源最短路径集合S并不断地做贪心选择来扩充集合。
每次遍历到始点距离最近且未访问过的顶点的邻接节点,添加到S中,直到扩展到终点为止
仅可以计算正权边E
算法:
(
)初始化(单源最短路径集合S为空,每个顶点对应的单步最短路径集合c[i][j],和每个顶点到源的最短距离集合dist[i])
(
)贪心算法,单步u从源点开始,选择c中最短的单步距离,dist[u] += c[u][j];
(
)更新单步为当前节点u,重复(
)
(
)如果当前算的dist[u] + c[u][j] <dist[j], 则修改dist[j] = dist[u] + c[u][j]
(
)重复(
-
),共n-1次
本质就是每次选一个离源点最近的点,直到选完所有的节点,但无法解决负权边
void Dijstra(int u)
{
const int inf = 0x3f3f3f3f; //用来标记不通的路
const int maxx = 1005;
int p[maxx]; //用来记录前驱节点,其实这道题用不到这个数组
int dist[maxx]; //源点到i之间的最短距离
int e[105][105]; //记录路径
int n, m;
int vis[maxx]; //标记这个点是否被访问过
memset(vis, 0, sizeof(vis));
dist[u] = 0;
for (int i = 1; i <= n; i++) {
if (i != u) {
dist[i] = e[u][i];
p[i] = u;
}
}
vis[u] = 1;
for (int i = 1; i <= n; i++) {
int temp = inf;
int t = u;
for (int j = 1; j <= n; j++) {
if (!vis[j] && dist[j] < temp) {
temp = dist[j];
t = j;
}
}
if (t == u)
break;
vis[t] = 1;
for (int j = 1; j <= n; j++) {
if (e[t][j] < inf) {
if (!vis[j] && dist[j] > dist[t] + e[t][j]) {
dist[j] = dist[t] + e[t][j];
p[j] = t;
}
}
}
}
}
- Bellman-Ford(贝尔曼福德)算法 / SPFA(Shortest Path Faster Algorithm)算法
贪心算法,在Dijkstra的基础上,每一次更新节点后,还需要对节点的信息进行更新
即,第1轮在对所有的边进行遍历后,得到的是“只能经过一条边”到达其余各顶点的最短路径长度。
第2轮再对所有的边进行遍历后,得到的是从S点“最多经过两条边”到达其余各顶点的当前最短路径长度。
如果进行k轮的话,得到的就是1号顶点“最多经过k条边”到达其余各顶点的当前最短路径长度
可以计算正、负权边E
本质就是每轮用所有边对节点进行松弛操作,得到“顶点最多经过m条边”,从而得出的每轮当前的最短路径,效果最好。
void Bellman_Ford (int n)
{
long long dis[n]; //最短距离
int u[n], v[n], w[n]; //存储图
for (int k = 1; k <= n - 1; k++) {
int flag = 0;
for (int i = 1; i <= m; i++) {
if (dis[v[i]] > dis[u[i]] + w[i]) {
dis[v[i]] = dis[u[i]] + w[i];
flag = 1;
}
}
if (!flag)
break;
}
}
2. 深度优先搜索(DFS)
深度优先搜索(depth-first seach,DFS)在搜索到一个新的节点时,立即对该新节点进行遍
历,由于总是对新节点调用遍历,因此看起来是向着“深”的方向前进。
1
/ \
2 3
/
4
考虑上述树,我们从1 号节点开始遍历,假如遍历顺序是从左子节点到右子节点,
那么按照优先向着“深”的方向前进的策略,假如我们使用递归实现,我们的遍历过程为1(起
始节点)->2(遍历更深一层的左子节点)->4(遍历更深一层的左子节点)->2(无子节点,返回父结点)->1(子节点均已完成遍历,返回父结点)->3(遍历更深一层的右子节点)->1(无子节点,返回父结点)-> 结束程序(子节点均已完成遍历)。如果我们使用栈实现,我们的栈顶元素的变化过程为1->2->4->3。
因此遍历需要用先入后出的栈来实现,也可以通过与栈等价的递归来实现
题目:给定一个二维的0-1 矩阵,其中0 表示海洋,1 表示陆地。单独的或相邻的陆地可以形成岛屿,每个格子只与其上下左右四个格子相邻。求最大的岛屿面积。
输入输出样例:输入是一个二维数组,输出是一个整数,表示最大的岛屿面积。
Input:
[[1,0,1,1,0,1,0,1],
[1,0,1,1,0,1,1,1],
[0,0,0,0,0,0,0,1]]
Output: 6
int maxAreaOfIsland(vector<vector<int>> &grid)
{
if (grid.empty() || grid[0].empty())
return 0;
int max_area = 0;
for (int i = 0; i < grid.size(); ++i) {
for (int j = 0; j < grid[0].size(); ++j)
max_area = max(max_area, dfs(grid, i, j));
}
return max_area;
}
// 辅函数
int dfs(vector<vector<int>> &grid, int r, int c)
{
if (r < 0 || r >= grid.size() || c < 0 || c >= grid[0].size() || grid[r][c] == 0)
return 0;
grid[r][c] = 0;
return 1 + dfs(grid, r + 1, c) + dfs(grid, r - 1, c) + dfs(grid, r, c + 1) + dfs(grid, r, c - 1);
}
3. 广度优先搜索(BFS)
广度优先搜索(breadth-first search,BFS)不同与深度优先搜索,它是一层层进行遍历的,由于是按层次进行遍历,广度优先搜索时按照“广”的方向进行遍历的,也常常用来处理最短路径等问题。
1
/ \
2 3
/
4
同样考虑上述树,我们从1 号节点开始遍历,假如遍历顺序是从左子节点到右子节点,那么按照优先向着“广”的方向前进的策略,队列顶端的元素变化过程为[1]->[2->3]->[4],其中
方括号代表每一层的元素。因此需要用先入先出的队列而非先入后出的栈进行遍历
题目:给定一个二维0-1 矩阵,其中1 表示陆地,0 表示海洋,每个位置与上下左右相连。已知矩阵中有且只有两个岛屿,求最少要填海造陆多少个位置才可以将两个岛屿相连。
输入输出样例:输入是一个二维整数数组,输出是一个非负整数,表示需要填海造陆的位置数。
Input:
[[1,1,1,1,1],
[1,0,0,0,1],
[1,0,1,0,1],
[1,0,0,0,1],
[1,1,1,1,1]]
Output: 1
// 主函数
int shortestBridge(vector<vector<int>> &grid)
{
int m = grid.size(), n = grid[0].size();
queue<pair<int, int>> points; // dfs寻找第一个岛屿,并把1全部赋值为2
bool flipped = false;
for (int i = 0; i < m; ++i) {
if (flipped)
break;
for (int j = 0; j < n; ++j) {
if (grid[i][j] == 1) {
dfs(points, grid, m, n, i, j);
flipped = true;
break;
}
}
} // bfs寻找第二个岛屿,并把过程中经过的0赋值为2
int x, y;
int level = 0;
while (!points.empty()) {
++level;
int n_points = points.size();
while (n_points--) {
auto [r, c] = points.front();
points.pop();
for (int k = 0; k < 4; ++k) {
x = r + direction[k], y = c + direction[k + 1];
if (x >= 0 && y >= 0 && x < m && y < n) {
if (grid[x][y] == 2)
continue;
if (grid[x][y] == 1)
return level;
points.push({x, y});
grid[x][y] = 2;
}
}
}
}
return 0;
}
// 辅函数
void dfs(queue<pair<int, int>> &points, vector<vector<int>> &grid, int m, int n, int i, int j)
{
if (i < 0 || j < 0 || i == m || j == n || grid[i][j] == 2)
return;
if (grid[i][j] == 0) {
points.push({i, j});
return;
}
grid[i][j] = 2;
dfs(points, grid, m, n, i - 1, j);
dfs(points, grid, m, n, i + 1, j);
dfs(points, grid, m, n, i, j - 1);
dfs(points, grid, m, n, i, j + 1);
}
4. DFS vs BFS
这里要注意,深度优先搜索和广度优先搜索都可以处理可达性问题,即从一个节点开始是否
能达到另一个节点。因为深度优先搜索可以利用递归快速实现,很多人会优先使用深度优先搜索。
实际软件工程中,尽量减少递归的写法,因为一方面难以理解,另一方面可能产生栈溢出的情况;
用栈实现的深度优先搜索和用队列实现的广度优先搜索在写法上并没有太大差异,因此使用哪一种搜索方式需要根据实际的功能需求来判断。
5. 拓扑排序(有向无环图DAG的建立)
题目:给定N个课程和这些课程的前置必修课,求可以一次性上完所有课的顺序
输入样例:输入是一个正整数表示课程数量,和一个二维矩阵表示所有的有向边(如[1,0] 表示上课程1之前必须先上课程0)。输出是一个一维数组表示拓扑排序结果。
Input:numCourses = 4,prerequisites = [[1,0],[2,0],[3,1],[3,2]]
Output:[0,1,2,3]
- 图通常有两种表示方法,假设图中一共有n 个节点、m 条边
1、邻接矩阵:建立一个n × n的矩阵G,如果第i 个节点连向第j 个节点,则G[i][j]= 1,反之为0;如果图是无向的,则这个矩阵一定是对称矩阵,即G[i][j] = G[j][i]。
2、邻接链表:建立一个大小为n的数组,每个位置i储存一个数组或者链表,表示第i个节点连向的其它节点。
邻接矩阵空间开销比邻接链表大,但是邻接链表不支持快速查找i 和j 是否相连,因此两种表示方法可以根据题目需要适当选择。除此之外,我们也可以直接用一个m × 2 的矩阵储存所有的边。
解:我们可以先建立一个邻接矩阵表示图,方便进行直接查找。
这里注意我们将所有的边反向,使得如果课程i指向课程j,那么课程i需要在课程j前面先修完。这样更符合我们的直观理解。
拓扑排序也可以被看成是广度优先搜索的一种情况:我们先遍历一遍所有节点,把入度为0的节点(即没有前置课程要求)放在队列中。在每次从队列中获得节点时,我们将该节点放在目前排序的末尾,并且把它指向的课程的入度各减1;如果在这个过程中有课程的所有前置必修课都已修完(即入度为0),我们把这个节点加入队列中。
当队列的节点都被处理完时,说明所有的节点都已排好序,或无法上完所有课程。
vector<int> findOrder(int numCourses, vector<vector<int>> &prerequisites)
{
vector<vector<int>> graph(numCourses, vector<int>()); // 邻接矩阵
vector<int> indegree(numCourses, 0), res; // 入度向量
for (const auto &prerequisite : prerequisites) {
graph[prerequisite[1]].push_back(prerequisite[0]); //初始化邻接矩阵
++indegree[prerequisite[0]]; //初始化入度向量
}
queue<int> q;
for (int i = 0; i < indegree.size(); ++i) { //判断入度为0的课程
if (!indegree[i])
q.push(i);
}
while (!q.empty()) {
int u = q.front(); //DAG的根节点
res.push_back(u);
q.pop();
for (auto v : graph[u]) { // 根节点对应的子节点入度-1
--indegree[v];
if (!indegree[v])
q.push(v); // 若子节点中存在入度为0的节点,则连接到DAG中
}
}
for (int i = 0; i < indegree.size(); ++i) { //判断是否有无法完成的课程
if (indegree[i])
return vector<int>();
}
return res;
}
6. 最小生成树
最小生成树是一副连通加权无向图中一棵权值最小的生成树。
即,我们用线段把图所有的顶点进行连接,连接时不能产生圈,并且所有边权值的和为最小,最小生成树可能不是唯一的
- Prim(普里姆)算法
首先选择一个根节点,然后让其慢慢的生成一个树,也就是给其加边,每次加边时所选择的顶点是来自于其前一个顶点,直到图中的所有顶点加到树上。
本质是贪心算法,与Dijkstra算法类似,dis的作用是存储未知顶点到已知顶点的权值,且加入的边的数量比顶点数少一
void Prim(int n, int u)
{
int city[100][100]; //邻接矩阵
bool used[100]; //遍历过的节点
int point[100], edge[100]; //最邻近点,以及到最邻近点的边值
used[u] = true;
for (int i = 1; i <= n; i++)
{
if (i == u)
point[i] = 0;
else {
point[i] = u;
edge[i] = city[u][i];
used[i] = false;
}
}
int u0 = u;
for (int i = 1; i <= n; i++) {
int temp = 100000;
for (int j = 1; j <= n; j++) {
if (!used[j] && edge[j] < temp) {
u0 = j;
temp = edge[j];
}
}
if (u0 == u)
break;
used[u0] = true;
for (int j = 1; j <= n; j++) {
if (!used[j] && city[u0][j] < edge[j]) {
edge[j] = city[u0][j];
point[j] = u0;
}
}
}
}
- Kruskal(克鲁斯卡尔)算法
将连通图的每一条边按边权的大小,从小到大排列,每次选取还未选取的边中边权最小的边,判断一下,如果选这条边会和已选的边形成环,(或者说所选的这条边连接的两个点已经在一个集合里了),则不选,否则选取
struct Edge
{
int v;
int u;
int w;
} e[100];
int father[100];
bool cmp(Edge a, Edge b)
{
return a.w < b.w;
}
void Init(int n)
{
for (int i = 1; i <= n; i++)
father[i] = i;
}
int FindFather(int i)
{
if (i == father[i])
return i;
else {
int f = FindFather(father[i]);
father[i] = f;
return f;
}
}
void Kruskal(int n, int m, int& sum)
{
sort(e + 1, e + m + 1, cmp);
int sum = 0;
for (int i = 1; i <= m; i++) {
int fv = FindFather(e[i].v);
int fu = FindFather(e[i].u);
if (fu != fv) {
sum += e[i].w;
father[fv] = fu;
n--;
if (n == 1)
return sum;
}
}
}