第三章:搜索与图论

178 阅读8分钟

DFS 和 BFS

数据结构空间复杂度性质
DFSstack栈O(n)O(n)不具有最短性,暴力搜索满足条件的结果
BFSqueue队列O(2n)O(2^n)最短路径性质

深度优先搜索 DFS(暴力搜索)

全排列问题

题目:leetcode-cn.com/problems/pe…

class Solution {
    //path 存储路径
    List<Integer> path = new ArrayList<>();
    //存储最终结果
    List<List<Integer>> ans = new ArrayList<>();

    public List<List<Integer>> permute(int[] nums) {
        int len = nums.length;
        if (len == 0) return ans;
        //用于nums记录的元素是否被使用过
        boolean[] st = new boolean[len];
        dfs(nums, 0, len, st);
        return ans;
    }

    public void dfs(int[] nums, int u, int len, boolean[] st) {
        //表示已经遍历到最后一个位置,即最后一层
        if (u == len) {
            ans.add(new ArrayList<Integer>(path));
            return ;
        }

        for (int i = 0; i < len; i++) {
            //表示nums当前元素没有被使用,可以使用
            if (st[i] != true) {
                path.add(nums[i]);
                st[i] = true;
                //dfs,往下递归
                dfs(nums, u + 1, len, st);
                //下面两行用于在回溯前恢复现场
                path.remove(path.size() - 1);
                st[i] = false;
            }
        }

    }
}

N皇后问题

题目位置:leetcode-cn.com/problems/n-…

baike.baidu.com/item/%E7%9A…N皇后介绍

class Solution {

    int N = 20;
	  // 存储满足要求的皇后摆放的结果
    char[][] g = new char[N][N];
  	//加list是因为返回需要的是list
    List<List<String>> ans = new ArrayList<>();
    List<String> level = new ArrayList<>();

    public List<List<String>> solveNQueens(int n) {
        if (n == 0) return ans;
        
        //设置数组g元素都是‘.’
        for (int i = 0; i < n; i++) {
            for (int j = 0; j < n; j++) {
                g[i][j] = '.';
            }
        }

        //当前元素是被使用,那么该元素所在的列也不能被放置元素
        boolean[] col = new boolean[N];
        //当前元素的右斜方向
        boolean[] dg = new boolean[N];
      	//当前元素的左斜方向
        boolean[] udg = new boolean[N];

        dfs(0, n, col, dg, udg);
        return ans;
        
    }

    public void dfs(int u, int n, boolean[] col, boolean[] dg, boolean[] udg) {
        //将数组转成list
        if (u == n) {
            for (int i = 0; i < n; i++) {
                String tmp = "";
                for (int j = 0; j < n; j++) {
                    tmp += g[i][j];
                }
                level.add(new String(tmp));
            }
            ans.add(new ArrayList<>(level));
            level.clear();
            return ;            
        }

        //遍历所有满足条件的结果
        for (int i = 0; i < n; i++) {
            if (col[i] != true && dg[u + i] != true && udg[n - u + i] != true) {
                g[u][i] = 'Q';
                col[i] = true;
                
                dg[u + i] = true;
                udg[n - u + i] = true;

                //当前层满足,则进入到下一层,直到最后一层
                dfs(u+1,n, col, dg, udg);

                //恢复现场
                col[i] = false;
                dg[u + i] = false;
                udg[n - u + i] = false;
                g[u][i] = '.';
            }
        }
    }
}

宽度优先搜索 BFS(可以搜索到最短路径)

DFS深度搜索可以保证搜到终点,但是不能保证是最短的。

当所有边的权重都是一样的时候,才可以用BFS来算;当权重不一样的时候,需要使用的专门的最短路径算法进行计算。

queue <- 初始化
while (queue 不为空) {
		 t <- 队头
     拓展
}

例题:走迷宫 www.acwing.com/problem/con…

地图分析:leetcode-cn.com/problems/as…

对于图的BFS与Tree的BFS区别如下:

1、tree只有1个root,而图可以有多个源点,所以首先需要把多个源点都入队。

2、tree是有向的因此不需要标志是否访问过,而对于无向图来说,必须得标志是否访问过!

并且为了防止某个节点多次入队,需要在入队之前就将其设置成已访问!

//存储路径
class Pair { // 其实可以使用int[2] 数组存储
    int x;
    int y;  
    public Pair(int x, int y) {
        this.x = x;
        this.y = y;
    }
}
public class Main {
    //地图
    static int[][] map = null;
    //保存走过的路,数组d的元素表示当前走了多少步
    //当没有走过的话,则该值为0
    static int[][] d = null;
    
    //用于记录当前位置是从之前哪个位置过来的,便于输出路径
    static Pair[][] prev = null;
    
    static int n;
    static int m;
    
    public static void main(String[] args) throws IOException {
        InputStreamReader in = new InputStreamReader(System.in);
        BufferedReader br = new BufferedReader(in);
        String[] nums = br.readLine().split(" ");
        
        n = Integer.parseInt(nums[0]);
        m = Integer.parseInt(nums[1]);
        
        map = new int[n][m];
        d = new int[n][m];
        prev = new Pair[n][m];
                
        //迷宫map
        for(int i = 0; i < n; i++) {
            String[] inputs = br.readLine().split(" ");
            for (int j = 0; j < m; j++) {
                map[i][j] = Integer.parseInt(inputs[j]);
            }
        }
        
        System.out.println(bfs());        
    }
    
    public static int bfs() {
        //初始化队列
        Queue<Pair> q = new LinkedList<Pair>();
                      
        int[] dy = {0, 1, 0, -1}, dx = {-1, 0, 1, 0};
        //加入起点0, 0
        q.offer(new Pair(0, 0));
        
        while (!q.isEmpty()) {
            Pair pair = q.poll();
            if (pair.x == n - 1 && pair.y == m - 1) {
                break;
            }            
            //上左下右 遍历
            for (int i = 0; i < 4; i++) {
                int x = pair.x + dx[i];
                int y = pair.y + dy[i];
                if (x >= 0 && x < n && y >= 0 && y < m && map[x][y] == 0 && d[x][y] == 0) {
                    q.offer(new Pair(x, y));
                    d[x][y] = d[pair.x][pair.y] + 1;
                    //存储能到当前x,y的位置
                    prev[x][y] = pair;
                }
            }
        }
        
        //从终点往前遍历到起点
        int x = n - 1, y = m - 1;
        while (x != 0 || y != 0) {
            System.out.println(x + " " + y);
            //prev[x][y] 存储的是能到达当前位置的位置
            Pair tmp = prev[x][y];
            x = tmp.x;
            y = tmp.y;
        }        
        return d[n - 1][m - 1];
    }
}

树与图的存储

(1) 邻接矩阵

g[a][b] 存储边a->b,g[a][b]的值存储的是边的权重

(2) 邻接表

// 对于每个点k,开一个单链表,存储k所有可以走到的点。
// h[k]存储单链表的头结点,这里因为存在多个节点,所以有多个单链表
// h[k]解释:下标k是头节点的值,h[k]存储的值是下个元素在e[]中的下标
// e[N] 每个节点的值
// ne[N] 存储 next指针
static int N = 100010, M = N * 2;
int[] h = new int[N], e = new int[M], ne = new int[M];
int idx = 0;
//记录已经被遍历过的点
boolean[] st = new int[N];

// 添加一条边a->b
void add(int a, int b){
    e[idx] = b, ne[idx] = h[a], h[a] = idx ++ ;
}

//将所有的头节点初始化为-1
//for (int i = 0; i < N; i++) {
//  	h[i] = -1;
//}
Arrays.fill(h, -1);

void dfs(int u) {
  	st[u] = true;//标记一下,已经被搜索过了
    for (int i = h[u]; i != -1; i = ne[i]) {
      	int j = e[i];
        if (st[j] != 0) dfs(j);
    }
}

拓扑排序

只有有向图才会有拓扑序列,有向无环图一定存在拓扑图。

无环图一定至少存在一个节点的入度为0,而一个有环图一定不存在一个节点的入度为0;

时间复杂度 O(n+m)O(n+m), n 表示点数,m 表示边数

queue <- 所有入度为0的点
while (queue 不为空) {
		t <- 队头;
		枚举t的所有出边 t -> j
		删除t -> j, d[j]--;
		if (d[j] == 0) queue <- j;
}

模板代码

bool topsort(){
    int hh = 0, tt = -1;

    // d[i] 存储点i的入度
    for (int i = 1; i <= n; i ++ ) {
        if (d[i] != 0) {
          	q[ ++ tt] = i;
        }
    }

    while (hh <= tt) {
        int t = q[hh ++ ];

        for (int i = h[t]; i != -1; i = ne[i]) {
            int j = e[i];
            if (-- d[j] == 0)
                q[ ++ tt] = j;
        }
    }

    // 如果所有点都入队了,说明存在拓扑序列;否则不存在拓扑序列。
    return tt == n - 1;
}

最短路问题

最短路问题分类

  1. 单源最短路问题
    1. 所有边权重都是正数(解决方法:dijkstra算法
    2. 存在负权边,即存在某些边的权重是负数(解决方法:Bellman-Ford 算法 和 spfa算法
  2. 多源汇最短路问题(源点即起点,汇点即终点)(解决方法:floyd算法

最短路问题的核心在于根据给定的数据建图。

总结:优先选择spfa算法。

dijkstra算法

(1)朴素dijkstra算法

适用于稠密图,即边数多。使用邻接矩阵存储路径

算法思路:

初始化距离:dist[1] = 0, dist[i] = 无穷大
s: 当前已经确定最短路径的点
for i : n 
		t : 不在s中的距离最近的点
		t 加到 s 中
		用 t 更新其他点的距离

时间复杂是 O(n2+m)O(n^2+m), n 表示点数,m 表示边数

static int[][] g = new int[N][N];  // 存储每条边
static int[] dist = new int[N];  // 存储1号点到每个点的最短距离
static boolean[] st = new int[N];   // 存储每个点的最短路是否已经确定
static int max = Integer.MAX_VALUE;
//初始化
for (int i = 0; i < n;i ++) {
    for (int j = 0; j < m; j++) {
        if (i == j) g[i][j] = 0;
        else g[i][j] = max;
    }
}

// 求1号点到n号点的最短路,如果不存在则返回-1
public int dijkstra(){  
    //将距离初始化为正无穷
  	Arrays.fill(dist, max);
    dist[1] = 0;
		
    for (int i = 0; i < n - 1; i ++ ) {
        // 还未确定最短路的点中,寻找到起点距离最小的点t
        int t = -1;     
        for (int j = 1; j <= n; j ++ ) {
          	if (st[j] != true && (t == -1 || dist[t] > dist[j])) t = j;
        }
        //节点t加入到集合S中
        st[t] = true;
      
        // 用t更新其他点的距离
        for (int j = 1; j <= n; j ++ ) {
          	dist[j] = min(dist[j], dist[t] + g[t][j]);
        }                    
    }

    if (dist[n] == max) return -1;
    return dist[n];
}
(2)堆优化版dijkstra

适用于稀疏图,即m边数较少。

时间复杂度 O(mlogn)O(mlogn), n 表示点数,m 表示边数

typedef pair<int, int> PII;

int n;      // 点的数量
int[] h = new int[N], w = new int[N], e = new int[N], ne = new int[N];// 邻接表存储所有边
int idx;
int[] dist = new int[N];        // 存储所有点到1号点的距离
boolean st = new int[N];     // 存储每个点的最短距离是否已确定

// 求1号点到n号点的最短距离,如果不存在,则返回-1
int dijkstra(){
  	Arrays.fill(dist, 0X3f3f3f3f);
    
    dist[1] = 0;
    //使用优先队列存储距离
    priorityQueue<PII, vector<PII>, greater<PII>> heap;
    heap.push({0, 1});      // first存储距离,second存储节点编号

    while (heap.size()) {
        auto t = heap.top();
        heap.pop();

        int ver = t.second, distance = t.first;

        if (st[ver] == true) continue;
        st[ver] = true;

        //使用当前这个点更新其他点
        for (int i = h[ver]; i != -1; i = ne[i]) {
            int j = e[i];
            if (dist[j] > distance + w[i]) {
                dist[j] = distance + w[i];
                heap.push({dist[j], j});
            }
        }
    }

    if (dist[n] == 0x3f3f3f3f) return -1;
    return dist[n];
}

Bellman-Ford算法

时间复杂度O(nm) O(nm), n 表示点数,m 表示边数

用于解决有负权边的情况,注意,在这种情况下可能不存在最短路径。

要限定走过的路径边数

注意在模板题中需要对下面的模板稍作修改,加上备份数组,详情见模板题。

int n, m;       // n表示点数,m表示边数
int[] dist = new int[N]; // dist[x]存储1到x的最短路距离

// 边,a表示出点,b表示入点,w表示边的权重
class Edge {     
    int a, b, w;
  	Edge(int a, int b, int c) {
       this.a = a; this.b = b; this.c = c;
    }
};

// 求1到n的最短路距离,如果无法从1走到n,则返回-1。
int bellmanFord(){
  	Arrays.fill(dist, 0x3f3f3f3f);
    //memset(dist, 0x3f, sizeof dist);
    dist[1] = 0;

    // 如果第n次迭代仍然会松弛三角不等式,就说明存在一条长度是n+1的最短路径,由抽屉原理,路径中至少存在两个相同的点,说明图中存在负权回路。
    for (int i = 0; i < n; i ++ ){
        for (int j = 0; j < m; j ++ ){
            int a = edges[j].a, b = edges[j].b, w = edges[j].w;
//            if (dist[b] > dist[a] + w)
//                dist[b] = dist[a] + w;
          	dist[b] = Math.min(dist[b], dist[a] + w);
        }
    }

    if (dist[n] > 0x3f3f3f3f / 2) return -1;
    return dist[n];
}

spfa 算法

**spfa算法本质上队列优化的Bellman-Ford算法。**使用spfa算法的前提是不能存在负环,当存在负环的时候使用Bellman-Ford算法。

时间复杂度 平均情况下 O(m)O(m),最坏情况下 O(nm)O(nm), n 表示点数,m 表示边数

int n;      // 总点数
int h = new int[N], w = new int[N], e = new int[N], ne = new int[N]; // 邻接表存储所有边
int idx;
int[] dist = new int[N];        // 存储每个点到1号点的最短距离
boolean st = new boolean[N];  // 存储每个点是否在队列中

// 求1号点到n号点的最短路距离,如果从1号点无法走到n号点则返回-1
int spfa(){
  	Arrays.fill(dist, 0x3f3f3f3f);
    //memset(dist, 0x3f, sizeof dist);
    dist[1] = 0;

    Queue<Integer> q = new LinkedList<>();
    q.push(1);
    st[1] = true;

    while (q.size() != 0){
        Integer t = q.front();
        q.pop();

        st[t] = false;

        for (int i = h[t]; i != -1; i = ne[i]){
            int j = e[i];
            if (dist[j] > dist[t] + w[i]){
                dist[j] = dist[t] + w[i];
                if (st[j] != true){     // 如果队列中已存在j,则不需要将j重复插入          
                    q.push(j);
                    st[j] = true;
                }
            }
        }
    }

    if (dist[n] == 0x3f3f3f3f) return -1;
    return dist[n];
}

spfa判断图中是否存在负环

时间复杂度是 O(nm)O(nm), n 表示点数,m 表示边数

int n;      // 总点数
int h[N], w[N], e[N], ne[N], idx;       // 邻接表存储所有边
int dist[N], cnt[N];        // dist[x]存储1号点到x的最短距离,cnt[x]存储1到x的最短路中经过的点数
bool st[N];     // 存储每个点是否在队列中

// 如果存在负环,则返回true,否则返回false。
bool spfa(){
    // 不需要初始化dist数组
    // 原理:如果某条最短路径上有n个点(除了自己),那么加上自己之后一共有n+1个点,由抽屉原理一定有两个点相同,所以存在环。

    queue<int> q;
    for (int i = 1; i <= n; i ++ )
    {
        q.push(i);
        st[i] = true;
    }

    while (q.size())
    {
        auto t = q.front();
        q.pop();

        st[t] = false;

        for (int i = h[t]; i != -1; i = ne[i])
        {
            int j = e[i];
            if (dist[j] > dist[t] + w[i])
            {
                dist[j] = dist[t] + w[i];
                cnt[j] = cnt[t] + 1;
                if (cnt[j] >= n) return true;       // 如果从1号点到x的最短路中包含至少n个点(不包括自己),则说明存在环
                if (!st[j])
                {
                    q.push(j);
                    st[j] = true;
                }
            }
        }
    }

    return false;
}

floyd算法

时间复杂度是 O(n3)O(n^3), n 表示点数

final static int INF = 0x3f3f3f3f;
//初始化:
    for (int i = 1; i <= n; i ++)
        for (int j = 1; j <= n; j ++)
            if (i == j) d[i][j] = 0;
            else d[i][j] = INF;

// 算法结束后,d[a][b]表示a到b的最短距离
void floyd(){
    for (int k = 1; k <= n; k ++)
        for (int i = 1; i <= n; i ++)
            for (int j = 1; j <= n; j ++)
                d[i][j] = min(d[i][j], d[i][k] + d[k][j]);
}

最小生成树

最小生成树的定义:最小生成树是在一个给定的无向图G(V,E)中求一颗树T,使得这棵树拥有图G中所有顶点,且所有边都是来自图G中的边,并且满足整棵树的边权之和最小。

问题种类:铺设公路

两种解决算法

  1. 普利姆算法(prim)

    1. 朴素版Prim算法(稠密图),时间复杂度O(n^2)
    2. 堆优化版Prim算法(稀疏图),时间 复杂度O(mlogn)(很少使用
  2. 克鲁斯卡尔算法(Kruskal)(稀疏图)

朴素版prim算法

时间复杂度是 O(n2+m)O(n2+m), n 表示点数,m 表示边数

static int n;      // n表示点数
static int INF = 0x3f3f3f3f;
static int[][] g = new int[N][N];        // 邻接矩阵,存储所有边
static int[] dist = new int[N];        // 存储其他点到当前最小生成树(集合)的距离
static boolean st = new int[N];     // 存储每个点是否已经在生成树中

// 如果图不连通,则返回INF(值是0x3f3f3f3f), 否则返回最小生成树的树边权重之和
int prim(){
  	Arrays.fill(dist, 0x3f);
    //memset(dist, 0x3f, sizeof dist);

    //所有生成树里的边的长度之和的最小值
    int res = 0;
    for (int i = 0; i < n; i ++ ){
        int t = -1;
        for (int j = 1; j <= n; j ++ ) {
           if (st[j] != false && (t == -1 || dist[t] > dist[j])) {
               t = j;
            }
        }

        if (i != 0 && dist[t] == INF) return INF;
        if (i != 0) res += dist[t];
        st[t] = true;

        for (int j = 1; j <= n; j ++ ) dist[j] = min(dist[j], g[t][j]);
    }

    return res;
}

Kruskal算法

时间复杂度是 O(mlogm)O(mlogm), n 表示点数,m 表示边数

思路:

  1. 将所有边按权重从小到大排序;

  2. 枚举每条边a,b,权重c

    if (a,b 不连通) 将这条边加入集合中

int n, m;       // n是点数,m是边数
int[] p = new int[N];       // 并查集的父节点数组

class Edge     // 存储边
{
    int a, b, w;
		Edge(int a, int b, int c) {
      	this.a = a;this.b = b; this.c = c;
    }
};

int find(int x){     // 并查集核心操作
    if (p[x] != x) p[x] = find(p[x]);
    return p[x];
}

public int kruskal(){
  	
    //sort(edges, edges + m);
    Arrays.sort(edge, new Comparator<Edge>() {
      @Override
      public int compare(Edge o1, Edge o2) {
        if(o1.w<o2.w)   return -1;
        else if(o1.w>o2.w)  return 1;
        else return 0;
      }
    });

    for (int i = 1; i <= n; i ++ ) p[i] = i;    // 初始化并查集

    int res = 0, cnt = 0;
    for (int i = 0; i < m; i ++ ) {
        int a = edges[i].a, b = edges[i].b, w = edges[i].w;

        a = find(a);
        b = find(b);
        if (a != b) {    // 如果两个连通块不连通,则将这两个连通块合并
            p[a] = b;
            res += w;
            cnt ++ ;
        }
    }

    if (cnt < n - 1) return INF;
    return res;
}

二分图

两种算法

  1. 染色法,O(n + m)
  2. 匈牙利算法,O(mn),实际运行时间一般远小于O(mn)

染色法判别二分图

时间复杂度是 O(n+m)O(n+m), n 表示点数,m 表示边数

int n;      // n表示点数
int h[N], e[M], ne[M], idx;     // 邻接表存储图
int color[N];       // 表示每个点的颜色,-1表示未染色,0表示白色,1表示黑色

// 参数:u表示当前节点,c表示当前点的颜色
bool dfs(int u, int c) {
    color[u] = c;
    for (int i = h[u]; i != -1; i = ne[i])
    {
        int j = e[i];
        if (color[j] == -1)
        {
            if (!dfs(j, !c)) return false;
        }
        else if (color[j] == c) return false;
    }

    return true;
}

bool check(){
    memset(color, -1, sizeof color);
    bool flag = true;
    for (int i = 1; i <= n; i ++ )
        if (color[i] == -1)
            if (!dfs(i, 0))
            {
                flag = false;
                break;
            }
    return flag;
}

匈牙利算法

时间复杂度是 O(nm)O(nm), n 表示点数,m 表示边数

int n1, n2;     // n1表示第一个集合中的点数,n2表示第二个集合中的点数
int h[N], e[M], ne[M], idx;     // 邻接表存储所有边,匈牙利算法中只会用到从第一个集合指向第二个集合的边,所以这里只用存一个方向的边
int match[N];       // 存储第二个集合中的每个点当前匹配的第一个集合中的点是哪个
bool st[N];     // 表示第二个集合中的每个点是否已经被遍历过

bool find(int x){
    for (int i = h[x]; i != -1; i = ne[i])
    {
        int j = e[i];
        if (!st[j])
        {
            st[j] = true;
            if (match[j] == 0 || find(match[j]))
            {
                match[j] = x;
                return true;
            }
        }
    }

    return false;
}

// 求最大匹配数,依次枚举第一个集合中的每个点能否匹配第二个集合中的点
int res = 0;
for (int i = 1; i <= n1; i ++ ){
    memset(st, false, sizeof st);
    if (find(i)) res ++ ;
}