经典基础算法总结(排序、二分、KMP、树、图)

经典算法与题目对应列表:https://leetcode.cn/circle/discuss/bawPH2/
算法大全:https://oi-wiki.org/basic/

1、排序(LC-912. 排序数组

图片来源:https://visualgo.net/en/sorting

给你一个整数数组 nums,请你将该数组升序排列。

插入排序

直接插入排序

class Solution {
    public int[] sortArray(int[] nums) {
        int n = nums.length; int j;
        for(int i = 1; i < n; i++){
            int k = nums[i];
            for(j = i-1; j >= 0 && nums[j] > k; j--){
                nums[j+1] = nums[j];
            }
            nums[j+1] = k;
        }
        return nums;
    }
}

折半插入排序

class Solution {
    public int[] sortArray(int[] nums) {
        int n = nums.length;
        for(int i = 1; i < n; i++){
            int k = nums[i];
            int left = 0, right = i;
            while(left < right){
                int mid = (left + right) >> 1;
                if(nums[mid] > k) right = mid;
                else left = mid + 1;
            }
            for(int j = i-1; j >= right; j--)
                nums[j+1] = nums[j];
            nums[right] = k;
        }
        return nums;
    }
}

希尔排序

希尔.gif

希尔排序 - 插入排序的改进版。为了减少数据的移动次数,在初始序列较大时取较大的步长,通常取序列长度的一半,此时只有两个元素比较,交换一次;之后步长依次减半直至步长为1,即为插入排序,由于此时序列已接近有序,故插入元素时数据移动的次数会相对较少,效率得到了提高。

class Solution {
    public int[] sortArray(int[] nums) {
        int j;
        int n = nums.length;
        for(int d = n/2; d >= 1; d = d/2){ //步长5 3 1
            for(int i = d; i < n; i++){ //从步长开始遍历
                int tmp = nums[i];
                //(直接插入排序)注意就j = j- d;
                for(j = i - d;j >= 0 && nums[j] > tmp; j -= d){
                    nums[j+d] = nums[j];
                }
                nums[j+d] = tmp;
            }
        }
        return nums;
    }
}

交换排序

冒泡排序

class Solution {
    public int[] sortArray(int[] nums) {
        int n = nums.length;
        for(int i = 0; i < n-1; i++){
            boolean isexchange = false;
            for(int j = n-1; j > i; j--){
                if(nums[j-1] > nums[j]){
                    isexchange = true;
                    swap(nums, j-1, j);
                }
            }
            if(isexchange == false) break; // 已经有序
        }
        return nums;
    }

    public void swap(int[] nums, int i, int j){
        int tmp = nums[i];
        nums[i] = nums[j];
        nums[j] = tmp;
    }
}

快速排序【重要】

class Solution {
    public int[] sortArray(int[] nums) {
        quickSort(nums, 0, nums.length-1);
        return nums;
    }

    public void quickSort(int[] nums, int low, int high){
        if(low < high){
            int pivot = Partition(nums, low, high);
            quickSort(nums, low, pivot-1);
            quickSort(nums, pivot+1, high);
        }
    }

    public int Partition(int[] nums, int low, int high){
        int pivot = nums[low];
        while(low < high){
            while(low < high && nums[high] >= pivot) high--;
            nums[low] = nums[high];
            while(low < high && nums[low] <= pivot) low++;
            nums[high] = nums[low];
        }
        nums[low] = pivot; // 最终位置在low = high; 
        return low;
    }
}

快速排序优化(针对多重复元素)

思路一:随机取得pivot,这是针对渐进有序的数组的情况,普通快速排序效率过低的问题,也就是上面提到的平衡树的两端。

//随机选取法
int RandomIndex = left + random.nextInt(right - left + 1);
swap(nums, left, RandomIndex);

思路二:三路快排(三指针法),把等于pivot元素的所有元素放在分割区间的中间,也就是说我们每次递归确定的是这个元素以及和它相等的元素的位置,大量元素相等的情况下,递归区间大大减少。

class Solution {
    private static final Random random = new Random();

    public int[] sortArray(int[] nums) {
        quickSort(nums, 0, nums.length-1);
        return nums;
    }

    public void quickSort(int[] nums, int left, int right){
        //递归退出条件
        if(left >= right)
            return;
        //随机选取法
        int randomidx = left + random.nextInt(right-left+1);
        swap(nums, left, randomidx);

        int pivot = partition(nums, left, right);
        quickSort(nums, left, pivot-1);
        quickSort(nums, pivot+1, right);
    }

    public int partition(int[] nums, int left, int right){
        int pivot = nums[left];
        while(left < right){
            while(left < right && nums[right] >= pivot) right--;
            nums[left] = nums[right];
            while(left < right && nums[left] <= pivot) left++;
            nums[right] = nums[left];
        }
        nums[left] = pivot; // 最终位置在low = high; 
        return left;
    }

    private void swap(int[] nums, int i, int j) {
        int temp = nums[i];
        nums[i] = nums[j];
        nums[j] = temp;
    }
}

选择排序

简单选择排序

class Solution {
    public int[] sortArray(int[] nums) {
        int n = nums.length;
        for(int i = 0; i < n; i++){
            int k = i;
            for(int j = i+1; j < n; j++){
                if(nums[k] > nums[j]) k = j;
            }
            swap(nums, i, k);
        }
        return nums;
    }
    public void swap(int[] nums, int i, int j){
        int tmp = nums[i];
        nums[i] = nums[j];
        nums[j] = tmp;
    }
}

堆排序【重要】

https://leetcode.cn/problems/sort-an-array/solution/javacha-ru-dui-gui-bing-kuai-pai-dui-shu-dm56/

思路:

将无序序列构建成一个堆,根据本题要求构建成大顶堆

将堆顶与末尾元素进行交换,将最大元素沉到数组末尾

重新调整结构,使其满足堆的定义,然后继续交换堆顶与数组末尾元素,反复执行调整+交换,直至整个数组有序

class Solution {
    public int[] sortArray(int[] nums) {
        // 重建大根堆
        for(int i = nums.length/2-1; i >= 0; i--){
            adjustHeap(nums, i, nums.length);
        }
        for(int i = nums.length-1; i > 0; i--){ //n-1趟交换
            swap(nums, i, 0); //输出堆顶元素
            adjustHeap(nums, 0, i);
        }
        return nums;
    }

    // 将元素位置为i的根的子树进行调整 
    public void adjustHeap(int[] arr, int idx, int length){
        int tmp = arr[idx];
        for(int k = idx*2+1; k < length; k = k*2 + 1){
            if(k+1 < length && arr[k] < arr[k+1]){
                k++; //找到key较大的子节点在下面进行比较
            }
            if(arr[k] > tmp){
                arr[idx] = arr[k]; //将arr[i]调整到双亲上
                idx = k; //继续向下调整 
            }else{
                break; // 已经满足根>左右
            }
        }
        arr[idx] = tmp;
    }

    public void swap(int[] nums, int i, int j){
        int tmp = nums[i];
        nums[i] = nums[j];
        nums[j] = tmp;
    }
}

①假定数组初始顺序如下
image.png
②从最后一个非叶子节点6开始调整
image.png
③向上调整上一个非叶子节点4
image.png
④调整完成后造成以4为根节点的堆结构混乱,继续调整
image.png
⑤将堆顶元素9与末尾元素4交换
image.png
⑥重新调整结构,此时调整的是[4,6,8,5]的结构
image.png
⑦再将堆顶元素8与末尾元素5进行交换,得到第二大元素8
image.png
⑧后续继续进行调整交换,直至整个数组有序
image.png

归并排序【重要】

归并排序是利用归并的思想实现的排序方法,先将数组递归分解,再对分解后的两个数组进行比较合并

class Solution {
    public int[] sortArray(int[] nums) {
        mergesort(nums, 0, nums.length-1);
        return nums;
    }

    public void mergesort(int[] nums, int left, int right){
        if(right <= left) return ;
        int mid = (left + right) / 2;
        //分解数组
        mergesort(nums, left, mid);
        mergesort(nums, mid+1, right);
        // 此时数组a的下标 [left, mid] 和下标 [mid + 1, right] 范围内数组已然有序
        // 合并数组,使得[left, right]有序
        merge(nums, left, mid, right);
    }

    public void merge(int[] nums, int left, int mid, int right){
        int[] tmp = new int[right - left + 1];
        int i = left, j = mid + 1, k = 0;
        while(i <= mid && j <= right){
            tmp[k++] = nums[i] < nums[j] ? nums[i++] : nums[j++];
        }
        while(i <= mid) tmp[k++] = nums[i++];
        while(j <= right) tmp[k++] = nums[j++];
        //将排序后的temp数组合并至原数组对应的索引部分
        for(int index = 0; index < tmp.length; index++) {
            nums[left + index] = tmp[index];
        }
    }
}

image.png


基数排序

基数排序.png

题解+代码:https://leetcode.cn/problems/sort-an-array/solution/fu-xi-ji-chu-pai-xu-suan-fa-java-by-liweiwei1419/

class Solution {
    private static final int OFFSET = 50000;
    public int[] sortArray(int[] nums) {
        int len = nums.length;
        // 预处理,让所有的数都大于等于 0,这样才可以使用基数排序
        for (int i = 0; i < len; i++) {
            nums[i] += OFFSET;
        }
        // 第 1 步:找出最大的数字
        int max = nums[0];
        for (int num : nums) {
            if (num > max) max = num;
        }
        // 第 2 步:计算出最大的数字有几位,这个数值决定了我们要将整个数组看几遍
        int maxLen = getMaxLen(max);

        // 计数排序需要使用的计数数组和临时数组
        int[] count = new int[10];
        int[] temp = new int[len];
        // 表征关键字的量:除数
        // 1 表示按照个位关键字排序
        // 10 表示按照十位关键字排序
        // 100 表示按照百位关键字排序
        // 1000 表示按照千位关键字排序
        int divisor = 1;
        // 有几位数,外层循环就得执行几次
        for (int i = 0; i < maxLen; i++) {

            // 每一步都使用计数排序,保证排序结果是稳定的
            // 这一步需要额外空间保存结果集,因此把结果保存在 temp 中
            countingSort(nums, temp, divisor, len, count);

            // 交换 nums 和 temp 的引用,下一轮还是按照 nums 做计数排序
            int[] t = nums;
            nums = temp;
            temp = t;

            // divisor 自增,表示采用低位优先的基数排序
            divisor *= 10;
        }

        int[] res = new int[len];
        for (int i = 0; i < len; i++) {
            res[i] = nums[i] - OFFSET;
        }
        return res;

    }

     private void countingSort(int[] nums, int[] res, int divisor, int len, int[] count) {
        // 1、计算计数数组
        for (int i = 0; i < len; i++) {
            // 计算数位上的数是几,先取个位,再十位、百位
            int remainder = (nums[i] / divisor) % 10;
            count[remainder]++;
        }

        // 2、变成前缀和数组
        for (int i = 1; i < 10; i++) {
            count[i] += count[i - 1];
        }

        // 3、从后向前赋值
        for (int i = len - 1; i >= 0; i--) {
            int remainder = (nums[i] / divisor) % 10;
            int index = count[remainder] - 1;
            res[index] = nums[i];
            count[remainder]--;
        }

        // 4、count 数组需要设置为 0 ,以免干扰下一次排序使用
        for (int i = 0; i < 10; i++) {
            count[i] = 0;
        }
    }

    private int getMaxLen(int num) {
        int maxLen = 0;
        while (num > 0) {
            num /= 10;
            maxLen++;
        }
        return maxLen;
    }
}

计数排序

计数排序的核心在于将输入的数据值转化为键存储在额外开辟的数组空间中。作为一种线性时间复杂度的排序,计数排序要求输入的数据必须是有确定范围的整数。

创建数组 counts,用于统计原数组 nums 中各元素值的出现次数;

再依次将元素值赋值到 nums 中对应位置。

计数排序,时间复杂度n + k,空间复杂度k(k = maxNum - minNum + 1)

class Solution {
    public int[] sortArray(int[] nums) {
        // CountSort 计数排序
        int n = nums.length;
        int minNum = Integer.MAX_VALUE, maxNum = Integer.MIN_VALUE;
        // 找到数组中的最小和最大元素
        for(int i = 0; i < n; i++){
            minNum = Math.min(minNum, nums[i]);
            maxNum = Math.max(maxNum, nums[i]);
        }
        // 构造计数数组
        int[] cnts = new int[maxNum - minNum + 1];
        for(int v : nums){
            cnts[v - minNum]++;
        }
        // 计数排序
        int index = 0;
        for(int i = 0; i < cnts.length; i++){
            while(cnts[i] != 0){
                nums[index++] = i + minNum;
                cnts[i]--;
            }
        }
        return nums;
    }
}

练习题:

2、二分查找(35. 搜索插入位置

①左闭右闭

class Solution {
    public int searchInsert(int[] nums, int target) {
        int left = 0, right = nums.length-1;
        while(left <= right){
            int mid = (left + right) >> 1;
            if(nums[mid] == target) return mid;
            else if(nums[mid] < target) left = mid+1;
            else right = mid-1;
        }
        return left;
    }
}

②左闭右开

class Solution {
    public int searchInsert(int[] nums, int target) {
        int left = 0, right = nums.length;
        while(left < right){
            int mid = (left + right) >> 1;
            if(nums[mid] == target) return mid;
            else if(nums[mid] < target) left = mid+1;
            else right = mid;
        }
        return left;
    }
}

3、字符串KMP算法(28. 找出字符串中第一个匹配项的下标

class Solution {
    // KMP算法(在s字符串中找t字符串的起始位置)
    public int strStr(String s, String t) {
        int[] next = get_next(t);
        int i = 0, j = 0;
        while(i < s.length() && j < t.length()){
            if(j == -1 || s.charAt(i) == t.charAt(j)){
                //当 j=0 时,也让主串i移动
                i++; j++;// 继续比较后继字符
            }else{
                j = next[j]; // 模式串向左移动 
            }
        }
        if(j == t.length()) return i - t.length();
        else return -1;
    }
    // next数组:当发生不匹配时,j应该回到的位置(注意是对模式串(目标串)构建next数组)
    public int[] get_next(String t){
        int[] next = new int[t.length()+1];
        int i = 0, j = -1;
        next[0] = -1;
        while(i < t.length()){
            if(j == -1 || t.charAt(i) == t.charAt(j)){
                i++; j++;
                next[i] = j;// 若pi = pj,则next[j+1] = next[j]+1
            }else{
                j = next[j];
            }
        }
        return next;
    }
}

4、二叉树

中序遍历非递归(94. 二叉树的中序遍历

建立一个栈,遍历节点p=root

根结点进栈,遍历左子树

根结点出栈,输出根结点,遍历右子树

class Solution {
    public List<Integer> inorderTraversal(TreeNode root) {
        List<Integer> res = new ArrayList<>();
        Stack<TreeNode> stack = new Stack<>();
        TreeNode p = root; // p指针遍历,初始指向根节点
        while(p != null || !stack.isEmpty()){
            if(p != null){
                stack.push(p);
                p = p.left;
            }else{ // p 为空时,遍历到最左下了
                p = stack.pop(); // 弹出元素并访问
                res.add(p.val);
                p = p.right; // 遍历右子树
            }
        }
        return res;
    }
}

中序遍历递归方式:

class Solution {
    List<Integer> res;
    public List<Integer> inorderTraversal(TreeNode root) {
        res = new ArrayList<>();
        if(root == null) return res;
        dfs(root);
        return res;
    }
    public void dfs(TreeNode node){
        if(node == null) return;
        dfs(node.left);
        res.add(node.val);
        dfs(node.right);
    }
}

后序遍历非递归(145. 二叉树的后序遍历

后序遍历二叉树是先访问左子树,再访问右子树,最后访问根结点。

算法思想:

  1. 先沿根结点,依次入栈,直到左孩子为空
  2. 读取栈顶元素;如果其右孩子不空且未被访问过,将右子树转执行 1;
  3. 否则,栈顶元素出栈并访问。
class Solution {
    public List<Integer> postorderTraversal(TreeNode root) {
        List<Integer> res = new ArrayList<>();
        Stack<TreeNode> stack = new Stack<>();
        TreeNode p = root; // p指针遍历,初始指向根节点
        TreeNode pre = null; // pre前驱指针
        while(p != null || !stack.isEmpty()){
            if(p != null){ //走到最左边 
                stack.push(p);
                p = p.left;
            }else{
                p = stack.peek(); // 读取栈顶节点
                if(p.right != null && p.right != pre){ //若右子树存在,且未被访问过 
                    p = p.right; // 访问右子树
                }else{ //否则弹出结点并访问 
                    p = stack.pop();
                    res.add(p.val);
                    pre = p; // 记录最近访问的结点 
                    p = null; // 结点访问完后,重置p指针
                }

            }
        }
        return res;
    }
}

后序遍历递归方式:

class Solution {
    List<Integer> res;
    public List<Integer> postorderTraversal(TreeNode root) {
        res = new ArrayList<>();
        if(root == null) return res;
        dfs(root);
        return res;
    }
    public void dfs(TreeNode node){
        if(node == null) return;
        dfs(node.left);
        dfs(node.right);
        res.add(node.val);
    }
}

层序遍历(515. 在每个树行中找最大值

class Solution {
    public List<Integer> largestValues(TreeNode root) {
        List<Integer> res = new ArrayList<>();
        if(root == null) return res;
        Deque<TreeNode> dq = new ArrayDeque<>();
        dq.addLast(root);
        while(!dq.isEmpty()){
            int size = dq.size();
            int max = Integer.MIN_VALUE;
            while(size-- > 0){
                TreeNode cur = dq.pollFirst();
                max = Math.max(max, cur.val);
                if(cur.left != null) dq.addLast(cur.left);
                if(cur.right != null) dq.addLast(cur.right);
            }
            res.add(max);
        }
        return res;
    }
}

5、图论

网格图遍历(求连通块大小)

参考 0x3f :https://leetcode.cn/problems/pond-sizes-lcci/solution/mo-ban-wang-ge-tu-dfsfu-ti-dan-by-endles-p0n1/

一、思考

  • 题目说「由垂直水平对角连接的水域为池塘」,那么如何遍历所有相邻的 0?
  • 在网格图中,是否会重复访问同一个格子?如何处理?
  • 网格图的遍历,与二叉树的遍历有何区别?与一般图的遍历有何区别?
  • 什么情况下用 DFS,什么情况下用 BFS?

二、解惑

我们从一个值为 0 格子出发,尝试移动到它的邻居,即周围八个方向(上、下、左、右、左上、右上、左下、右下)相邻的格子。如果邻居是 00,就移动到邻居,然后按同样的方法移动到邻居的邻居。由于每次都在做类似的事情,所以可以用递归解决。

在网格图中,向下移动后就不能再向上移动了。如果不做任何处理,那么会反复向下向上,无限递归下去。为了避免无限递归,可以用一个 vis 数组标记访问过的格子。例如 vis[2][3]=true 表示访问过第 2 行第 3 列的格子。

二叉树 vs 网格图 vs 一般图

重复访问邻居个数DFSBFS
二叉树≤3前中后序层序
网格图≤8连通块最短路
一般图任意连通块、判环等最短路等

注 1:通常情况下,网格图是四方向的,每个格子的邻居个数不超过 4。

注 2:BFS 也可以判断连通块,但要手动用队列保存待访问节点;而 DFS 是计算机帮你创建了一个,自动保存递归路径上的节点,不需要手动处理。所以代码上 DFS 通常比 BFS 要简短。

题单

网格图 DFS

网格图 BFS

综合应用


6403. 网格图中鱼的最大数目

给你一个下标从 0 开始大小为 m x n 的二维整数数组 grid ,其中下标在 (r, c) 处的整数表示:

  • 如果 grid[r][c] = 0 ,那么它是一块 陆地
  • 如果 grid[r][c] > 0 ,那么它是一块 水域 ,且包含 grid[r][c] 条鱼。

一位渔夫可以从任意 水域 格子 (r, c) 出发,然后执行以下操作任意次:

  • 捕捞格子 (r, c) 处所有的鱼,或者
  • 移动到相邻的 水域 格子。

请你返回渔夫最优策略下, 最多 可以捕捞多少条鱼。如果没有水域格子,请你返回 0

格子 (r, c) 相邻 的格子为 (r, c + 1)(r, c - 1)(r + 1, c)(r - 1, c) ,前提是相邻格子在网格图内。

题解:

在遍历每一个连通块时,可以将遍历过的位置值设置为0,这样就不用开vis数组了

BFS算法

class Solution {
    int[][] dirts = {{0, 1}, {0, -1}, {1, 0}, {-1, 0}};
    int[][] grid;
    int n, m;
    public int findMaxFish(int[][] grid) {
        int res = 0;
        n = grid.length; 
        m = grid[0].length;
        this.grid = grid;
        for(int i = 0; i < n; i++){
            for(int j = 0; j < m; j++)
                    res = Math.max(res, bfs(i, j));
        }
        return res;
    }
    
    public int bfs(int i, int j){
        if(i < 0 || i >= n || j < 0 || j >= m || grid[i][j] == 0)
            return 0;
        Deque<int[]> dq = new ArrayDeque<>();
        dq.add(new int[]{i, j});
        int tot = grid[i][j];
        grid[i][j] = 0;
        while(!dq.isEmpty()){
            int[] pos = dq.pollFirst();
            for(int[] d : dirts){
                int x = pos[0] + d[0], y = pos[1] + d[1];
                if(x < 0 || x >= n || y < 0 || y >= m || grid[x][y] == 0)
                    continue;
                tot += grid[x][y];
                grid[x][y] = 0;
                dq.addLast(new int[]{x, y});
            }
        }    
        return tot;
    }
}

DFS算法【🎉🎉】

class Solution {
    int[][] dirts = {{0, 1}, {0, -1}, {1, 0}, {-1, 0}};
    int[][] grid;
    int n, m;
    public int findMaxFish(int[][] grid) {
        int res = 0;
        n = grid.length; 
        m = grid[0].length;
        this.grid = grid;
        for(int i = 0; i < n; i++){
            for(int j = 0; j < m; j++)
                    res = Math.max(res, dfs(i, j));
        }
        return res;
    }

    public int dfs(int i, int j){
        if(i < 0 || i >= n || j < 0 || j >= m || grid[i][j] == 0)
            return 0;
        int result = grid[i][j];
        grid[i][j] = 0;
        for(int[] d : dirts){
            int x = i + d[0], y = j + d[1];
            result += dfs(x, y);
        }
        return result;
    }
}

并查集

class Solution {
    int[][] dirts = {{0, 1}, {0, -1}, {1, 0}, {-1, 0}};
    int n, m;

    int[] parent;
    int[] score;
    public int findMaxFish(int[][] grid) {
        n = grid.length; m = grid[0].length;
        parent = new int[n * m];
        score = new int[n * m]; // 用一维数组来表示并查集块的和
        for(int i = 0; i < n; i++){
            for(int j = 0; j < m; j++){
                score[i * m + j] = grid[i][j];
                parent[i * m + j] = i * m + j; 
            }
        }
        for(int i = 0; i < n; i++){
            for(int j = 0; j < m; j++){
                if(grid[i][j] == 0) continue;
                for(int[] d : dirts){
                    int x = i + d[0], y = j + d[1];
                    if(x >= 0 && x < n && y >= 0 && y < m && grid[x][y] > 0)
                        union(i * m + j, x * m + y);
                }
            }
        }
        int res = 0;
        for(int i = 0; i < n; i++){
            for(int j = 0; j < m; j++){
                res = Math.max(res, score[i * m + j]);
            }
        }
        return res;
    }

    public void union(int x, int y){
        int px = find(x);
        int py = find(y);
        if(px != py){
            // 全部将元素加到连通块中最后一个元素位置上,即当前遍历时添加到右侧(下侧)上
            parent[px] = py;
            score[py] += score[px];
        }
    }

    public int find(int x){
        if(parent[x] != x) parent[x] = find(parent[x]);
        return parent[x];
    }
}

网格图相关练习题


求单源最短路径(743. 网络延迟时间

Dijkstra算法

https://leetcode.cn/problems/network-delay-time/solution/gtalgorithm-dan-yuan-zui-duan-lu-chi-tou-w3zc/
五种最短路径算法总结:https://leetcode.cn/problems/network-delay-time/solution/dirkdtra-by-happysnaker-vjii/
另一个模板题:6336. 设计可以求最短路径的图类

单源最短路问题可以使用 Dijkstra 算法,其核心思路是贪心算法。流程如下:

  1. 首先,Dijkstra 算法需要从当前全部未确定最短路的点中,找到距离源点最短的点x。
  2. 其次,通过点x更新其他所有点距离源点的最短距离。例如目前点 A 距离源点最短,距离为 3;有一条 A->B 的有向边,权值为 1,那么从源点先去 A 点再去 B 点距离为 3 + 1 = 4,若原先从源点到 B 的有向边权值为 5,那么我们便可以更新 B 到源点的最短距离为 4
  3. 当全部其他点都遍历完成后,一次循环结束,将 x标记为已经确定最短路。进入下一轮循环,直到全部点被标记为确定了最短路。

1、 O ( n 2 ) O(n^2) O(n2)的Dijkstra解法:

class Solution {
    private static final int INF = Integer.MAX_VALUE/2;
    public int networkDelayTime(int[][] times, int n, int k) {
        // 邻接矩阵存储边信息
        int[][] g = new int[n][n];
        for(int i = 0; i < n; i++){
            Arrays.fill(g[i], INF); // 初始化路径不可达
        }
        for(int[] t : times){
            int x = t[0]-1, y = t[1]-1; // 边序号从0开始
            g[x][y] = t[2];
        }
        //################################################################
        int[] dist = new int[n]; // 从源点到某点的距离数组
        Arrays.fill(dist, INF);
        dist[k-1] = 0; // 由于从 k 开始,所以该点距离设为 0,也即源点

        boolean[] used = new boolean[n]; // 节点是否被更新数组

        for(int i = 0; i < n; i++){ // 每次遍历选择一个节点,一共遍历n次
            // 在还未确定最短路的点中,寻找距离最小的点
            int x = -1;
            for(int y = 0; y < n; y++){
                if(!used[y] && (x == -1 || dist[y] < dist[x])){
                    x = y;
                }
            }
            used[x] = true; // 用该点更新所有其他点的距离
            for(int y = 0; y < n; y++){
                dist[y] = Math.min(dist[y], dist[x] + g[x][y]);
            }
        }
        // 找到距离最远的点
        int ans = Arrays.stream(dist).max().getAsInt();
        return ans == INF ? -1 : ans;
    }
}

Floyd算法

算法思想:

该算法通常用以解决所有节点对的最短路径,该算法利用了这样一个有趣的事实:如果从节点 i 到节点 j,如果存在一条更短的路径的话,那么一定是从另一个节点 k 中转而来,即有 d[i][j] = min(d[i][j],d[i][k]+d[k][j]),而d[i][k]d[k][j]可以用一样的思想去构建,可以看出这是一个动态规划的思想。在构建i,j中,我们通过枚举所有的k值来进行操作。但是,该算法无法判断负权回路。

class Solution {
    private static final int INF = Integer.MAX_VALUE / 2;
    public int networkDelayTime(int[][] times, int n, int source) {
        int[][] g = new int[n][n];
        // 初始化邻接表
        for(int i = 0; i < n; i++){
            Arrays.fill(g[i], INF);
            // ###############################
            g[i][i] = 0; // i->i 自己到自己的距离初始化为 0
        }
        // 建立图,节点从0开始
        for(int[] t : times){
            g[t[0]-1][t[1]-1] = t[2];
        }
        // Floyd算法
        for(int k = 0; k < n; k++){
            for(int i = 0; i < n; i++){
                for(int j = 0; j < n; j++){
                    g[i][j] = Math.min(g[i][j], g[i][k] + g[k][j]);
                }
            }
        }
        // 最后统计答案的时候是从k到其他点的距离的最大值(最后一个更新的点)
        int res = 0;
        for(int i = 0; i < n; i++){
            res = Math.max(res, g[source-1][i]);
        }
        return res >= INF/2 ? -1 : res;
    }
}

其他问题:

问: 为什么一定要在最外层枚举 k ?

答: 仔细看上面的状态转移方程,要正确地算出 f[k+1][i][j] ,必须先把 f[k][i][j]f[k][i][k]f[k][k][j] 算出来。由于我们不知道 ki,j的大小关系,只有把 k 放在最外层枚举,才能保证先把 f[k][i][j]f[k][i][k]f[k][k][j] 算出来。顺带一提,对于 ij 来说,由于在计算 f[k+1][i][j] 的时候,f[k][.][.] 已经全部计算完毕,所以 ij 按照正序/逆序枚举都可以。


模板题:6336. 设计可以求最短路径的图类

1、Dijkstra算法解法:

class Graph {

    private static final int INF = Integer.MAX_VALUE / 2;
    int n;
    int[][] g;
    
    public Graph(int n, int[][] edges) {
        this.n = n;
        g = new int[n][n];
        for(int i = 0; i < n; i++) Arrays.fill(g[i], INF);
        for(int[] e : edges){
            int from = e[0], to = e[1], cost = e[2];
            g[from][to] = cost;
        }
    }
    
    public void addEdge(int[] edge) {
        int from = edge[0], to = edge[1], cost = edge[2];
        g[from][to] = cost;
    }
    
    public int shortestPath(int node1, int node2) {
        int[] dis = new int[n];
        Arrays.fill(dis, INF);
        dis[node1] = 0;
        boolean[] used = new boolean[n];
        for(int i = 0; i < n; i++){
            int x = -1;
            for(int y = 0; y < n; y++){
                if(!used[y] && (x == -1 || dis[y] < dis[x])){
                    x = y;
                }
            }
            used[x] = true;
            for(int y = 0; y < n; y++){
                dis[y] = Math.min(dis[y], dis[x] + g[x][y]);
            }
        }
        return dis[node2] == INF ? -1 : dis[node2];
    }
}

2、Floyd算法解法:

class Graph {
    /*
    Floyd定义:
    f[k][i][j] 表示除了i 和 j 之外,从 i 到 j 的路径中间点上至多为 k 的时候,从 i 到 j 的最短路的长度
    
    分类讨论:
        从 i 到 j 的最短路中间至多为 k-1 ==>
        从 i 到 j 的最短路中间至多为 k,说明 k 一定是中间节点
        f[k][i][j] = min(f[k-1][i][j], f[k-1][i][k] + f[k-1][k][j]) 
        维度优化: f[i][j] = min(f[i][j], f[i][k] + f[k][j])

        提问: 为什么维度优化,这样做还是对的? - k表示路径中间至多为k,不包含端点

    */

    private static final int INF = Integer.MAX_VALUE / 3;
    int n;
    int[][] g;
    
    public Graph(int n, int[][] edges) {
        this.n = n;
        g = new int[n][n];
        for(int i = 0; i < n; i++){
            // 邻接矩阵(初始化为无穷大,表示 i 到 j 没有边)
            Arrays.fill(g[i], INF);
            g[i][i] = 0; // i->i 的路径初始化为0
        }
        for(int[] e : edges){
            int from = e[0], to = e[1], cost = e[2];
            g[from][to] = cost;
        }
        // Floyd维护最短路径
        for(int k = 0; k < n; k++){
            for(int i = 0; i < n; i++){
                for(int j = 0; j < n; j++){
                    g[i][j] = Math.min(g[i][j], g[i][k] + g[k][j]); 
                }
            }
        }
    }
    
    public void addEdge(int[] edge) {
        int x = edge[0], y = edge[1], cost = edge[2];
        if(cost >= g[x][y]){
            return; // 无需更新,因为目前从 x->y 最短路比新加入的点小
        }
        // 更新i->j的路径,从新加入的节点处转移
        for(int i = 0; i < n; i++){
            for(int j = 0; j < n; j++){
                //  原来: i -> j
                //  更新后: i -> x -> y -> j
                g[i][j] = Math.min(g[i][j], g[i][x] + cost + g[y][j]);
            }
        }
    }
    
    public int shortestPath(int start, int end) {
        int ans = g[start][end];
        return ans < INF/ 3 ? ans : -1; 
    }
}

网格图BFS求最短路问题

二维BFS求最短路模板

class Solution {
    private final static int[][] directions = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}};
    public int shortestPath(int[][] grid) {
        int m = grid.length, n = grid[0].length;
        boolean[][] vis = new boolean[m][n];
        Deque<int[]> dq = new ArrayDeque<>();
        // 初始化队列,加入元素以启动BFS
        dq.add(new int[]{0, 0});
        vis[0][0] = true;
        int step = 0; // 遍历次数(所走次数,答案)
        while(!dq.isEmpty()){
            // 获取当前队列长度即同一层级(辈分)节点个数,并遍历
            int size = dq.size();
            while(size-- > 0){
                int[] curpos = dq.pollFirst();
                if(curpos[0] == m-1 && curpos[1] == n-1) // 判断是否到达答案点(通常是达到终点)
                    return step;
                // 向四周移动
                for(int i = 0; i < 4; i++){
                    int[] d = directions[i];
                    int newx = curpos[0] + d[0], newy = curpos[1] + d[1];
                    // 下个位置是否合法,或者未访问过
                    if(newx >= 0 && newx < m && newy >= 0 && newy < n 
                            && !vis[newx][newy] && grid[newx][newy] != 1){ // 通常会进行题目额外的判断,例如为`#`不能走
                        vis[newx][newy] = true;
                        dq.addLast(new int[]{newx, newy});
                    }
                }
            }
            step += 1; //进入下一层循环搜索
        }
        return -1;
    }
}

三维DFS模板

1293. 网格中的最短路径

给你一个 m * n 的网格,其中每个单元格不是 0(空)就是 1(障碍物)。每一步,您都可以在空白单元格中上、下、左、右移动。

如果您 最多 可以消除 k 个障碍物,请找出从左上角 (0, 0) 到右下角 (m-1, n-1) 的最短路径,并返回通过该路径所需的步数。如果找不到这样的路径,则返回 -1 。

题解:【为什么状态信息需要记录第三位状态】https://leetcode.cn/problems/shortest-path-in-a-grid-with-obstacles-elimination/solution/tu-jie-by-zhug-4-cst1/

对于二维网格中的最短路问题,一般利用BFS实现,并利用一个集合记录访问过的状态,从而避免死循环:

  • 对于不考虑可以越过障碍物的常规情况,只需记录已访问过的点坐标即可,因为某一点出发,之后可以走的所有情形(是否能走完、可以走的最短路径等信息)就是确定的,即点坐标(x, y)可与当前状态一一对应,BFS过程中如遇到已有状态则剪枝,说明当前已经绕了远路。
  • 对于此题,点坐标(x, y)本身不足以与当前状态一一对应:显然即使在同一位置上,可以越过障碍的剩余机会数目也会决定之后是否能走完、可以走的最短路径等信息,所以需要(x, y, rest)三元组来与当前状态一一对应
class Solution {
    /**
    本题核心逻辑还是BFS,只是对访问记录做了修改,之前记录是x和y(二维)被访问过,
                现在的记录是x,y,z,把二维的扩展为三维,其中z的取值是【0-k】,表示当前已经消除过多少障碍物。
    BFS算法的核心就是访问记录的保存,理解了访问记录的保存方法,就掌握了BFS的核心。
     */
    private final static int[][] directions = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}};
    public int shortestPath(int[][] grid, int k) {
        int m = grid.length, n = grid[0].length;
        boolean[][][] vis = new boolean[m][n][k+1];
        Deque<int[]> dq = new ArrayDeque<>();
        // 初始化队列,加入元素以启动BFS
        dq.add(new int[]{0, 0, 0});
        vis[0][0][0] = true;
        int step = 0; // 遍历次数(所走次数,答案)
        while(!dq.isEmpty()){
            // 获取当前队列长度即同一层级(辈分)节点个数,并遍历
            int size = dq.size();
            while(size-- > 0){
                int[] curpos = dq.pollFirst();
                // 判断是否到达答案点(通常是达到终点)
                if(curpos[0] == m-1 && curpos[1] == n-1)
                    return step;
                // 向四周移动
                for(int i = 0; i < 4; i++){
                    int[] d = directions[i];
                    int newx = curpos[0] + d[0], newy = curpos[1] + d[1], chance = curpos[2];
                    // 下个位置是否合法
                    if(newx >= 0 && newx < m && newy >= 0 && newy < n){ 
                        // **核心逻辑**:有障碍物要对其进行处理,计算是否还能消除障碍物
                        if(grid[newx][newy] == 1){
                            if(chance < k){ // 没有消除 k 个障碍物,可以继续消除
                                chance += 1;
                            }else{ // 已经消除了 k 个障碍物,这条路走不通
                                continue;
                            }
                        }
                        // 判断是否访问过
                        if(!vis[newx][newy][chance]){
                            vis[newx][newy][chance] = true;
                            dq.addLast(new int[]{newx, newy, chance});
                        }
                    }
                }
            }
            step += 1; //进入下一层循环搜索
        }
        return -1;
    }
}

三维BFS练习题:

最小生成树算法(1584. 连接所有点的最小费用

题解:https://leetcode.cn/problems/min-cost-to-connect-all-points/solution/1584-lian-jie-suo-you-dian-de-zui-xiao-f-i12e/

图来源:https://leetcode.cn/problems/min-cost-to-connect-all-points/solution/prim-and-kruskal-by-yexiso-c500/

Prim算法(选节点)

Prim 算法也使用贪心思想来让生成树的权重尽可能小,也就是切分定理。

切分定理:对于任意一种切分,其中权重最小的那条横切边一定是构成最小生成树的一条边

Prim算法先以某一个点进行切分,然后找到它权重最小的边及邻点,将其加入最小生成树集合MST,然后再以这两个点进行切分…

每次切分都能找到最小生成树的一条边,然后又可以进行新一轮切分,直到找到最小生成树的所有边为止

同时用一个布尔数组 inMST 辅助,防止重复计算横切边

可以用一个优先级队列存储这些横切边,就可以动态计算权重最小的横切边了

class Solution {
    public int minCostConnectPoints(int[][] points) {
        // 转化成无向图邻接表的形式
        List<int[]>[] graph = buildGraph(points);
        // 执行 Prim 算法
        Prim prim = new Prim(graph);
        return prim.minWeightSum;
    }

    // 转化成无向图邻接表的形式
    public List<int[]>[] buildGraph(int[][] points){
        int n = points.length;
        List<int[]>[] graph = new List[n];
        for(int i = 0; i < n; i++) graph[i] = new ArrayList<>();
        for(int i = 0; i < n; i++){
            for(int j = i+1; j < n; j++){
                int x1 = points[i][0], y1 = points[i][1];
                int x2 = points[j][0], y2 = points[j][1];
                int dis = Math.abs(x1 - x2) + Math.abs(y1 - y2); 
                // 用 points 中的索引表示坐标点
                // 无向图其实就是双向图
                // 一条边表示为 int[]{from, to, weight}
                graph[i].add(new int[]{i, j, dis});
                graph[j].add(new int[]{j, i, dis});
            }
        }
        return graph;
    }

    class Prim{
        // 核心数据结构,存储横切边的优先级队列
        // 按照边的权重从小到大排序
        PriorityQueue<int[]> pq = new PriorityQueue<>((a, b) -> (a[2] - b[2]));

        // 类似 visited 数组的作用,记录哪些节点已经成为最小生成树的一部分
        boolean[] inMST;
        
        int minWeightSum = 0; // 记录最小生成树的权重和

        // graph 是用邻接表表示的一幅图,graph[s] 记录节点 s 所有相邻的边,
        // 三元组 int[]{from, to, weight} 表示一条边
        List<int[]>[] graph;

        public Prim(List<int[]>[] graph){
            this.graph = graph;
            inMST = new boolean[graph.length];
            cnt(0); // 随便从一个点开始切分都可以,从节点 0 开始
            inMST[0] = true;
            // 不断进行切分,向最小生成树中添加边
            while(!pq.isEmpty()){
                int[] edge = pq.poll();
                int to = edge[1], weight = edge[2];
                if(inMST[to]){
                    // 节点 to 已经在最小生成树中,跳过,否则这条边会产生环
                    continue;
                }
                // 将边 edge 加入最小生成树
                cnt(to); // 节点 to 加入后,进行新一轮切分,会产生更多横切边
                inMST[to] = true;
                minWeightSum += weight;
            }
        }
        // 将 s 的横切边加入优先队列
        public void cnt(int v){
            for(int[] edge : graph[v]){
                // 相邻接点 to 已经在最小生成树中,跳过,否则这条边会产生环
                if(inMST[edge[1]])  continue;
                // 加入横切边队列
                pq.add(edge);
            }
        }

    }

}

image.png


Kruskal算法(选边)

Kruskal 算法需要用到 Union-Find 并查集算法

Kruskal 算法的一个难点是保证生成树的合法性,生成的树不能包含环,而 Union-Find 算法就擅长这个

对于添加的某条边,如果该边的两个节点本来就在同一连通分量里,那么添加这条边会产生环;反之,如果该边的两个节点不在同一连通分量里,则添加这条边不会产生环。

为了得到的这棵生成树是权重和最小的,用到了贪心思路:

将所有边按照权重从小到大排序,从权重最小的边开始遍历,如果这条边和 mst 中的其它边不会形成环,则这条边是最小生成树的一部分,将它加入 mst 集合;否则,这条边不是最小生成树的一部分,不要把它加入 mst 集合。

本题小技巧:每个坐标点是一个二元组,那么按理说应该用五元组表示一条带权重的边,但这样的话不便执行 Union-Find 算法;所以用 points 数组中的索引代表每个坐标点。

class Solution {
    public int minCostConnectPoints(int[][] points) {
        int n = points.length;
        // 生成所有边及权重
        ArrayList<int[]> edges = new ArrayList<>();
        for(int i = 0; i < n; i++){
            // ⭐注意j从i+1开始,不要重头开始,避免重复
            for(int j = i+1; j < n; j++){
                int x1 = points[i][0], y1 = points[i][1];
                int x2 = points[j][0], y2 = points[j][1];
                int dis = Math.abs(x1 - x2) + Math.abs(y1 - y2);
                edges.add(new int[]{i, j, dis});
            }
        }
        // 将边按照权重从小到大排序
        Collections.sort(edges, (a, b) -> (a[2] - b[2]));
        // 执行 Kruskal 算法
        UnionFind uf = new UnionFind(n);
        int minWeight = 0;
        for(int[] edge : edges){
            int i = edge[0], j = edge[1], weight = edge[2];
            // 如果i和j属于同一集合中,说明不能连接这条边,会产生环,不能加入mst
            if(uf.isConnected(i, j)) continue;
            // 若这条边不会产生环,则属于最小生成树
            uf.union(i, j);
            minWeight += weight;
        }
        return minWeight;
    }
}

class UnionFind{
    private int count;
    private int[] parent;
    
    public UnionFind(int n){
        parent = new int[n];
        count = 0;
        for(int i = 0; i < n; i++) parent[i] = i;
    }

    public void union(int x, int y){
        int rootx = find(x);
        int rooty = find(y);
        parent[rootx] = rooty;
        // 两个连通分量合并成一个连通分量,count数量减一
        count--;
    }

    public int find(int x){
        if(parent[x] != x){
            parent[x] = find(parent[x]);
        }
        return parent[x];
    }

    // 返回两者是否属于同一连通分量
    public boolean isConnected(int x, int y){
        return find(x) == find(y);
    }

    public int getCount(){ return count;}
}

image.png


拓扑排序(207. 课程表

题解:https://leetcode.cn/problems/course-schedule/solution/course-schedule-tuo-bu-pai-xu-bfsdfsliang-chong-fa/

算法流程:

  1. 统计课程安排图中每个节点的入度,生成 入度表 indegrees

  2. 借助一个队列 queue,将所有入度为 0 的节点入队。

  3. 当queue非空时,依次将队首节点出队,在课程安排图中删除此节点pre:

    • 并不是真正从邻接表中删除此节点 pre,而是将此节点对应所有邻接节点 cur 的入度 −1,即 indegrees[cur] -= 1

    • 当入度 −1后邻接节点 cur 的入度为 0,说明 cur 所有的前驱节点已经被 “删除”,此时将 cur 入队。

  4. 在每次pre出队时,执行numCourses–;

    • 若整个课程安排图是有向无环图(即可以安排),则所有节点一定都入队并出队过,即完成拓扑排序。换个角度说,若课程安排图中存在环,一定有节点的入度始终不为 00。

    • 因此,拓扑排序出队次数等于课程个数,返回 numCourses == 0 判断课程是否可以成功安排。

class Solution {
    public boolean canFinish(int numCourses, int[][] prerequisites) {
        int[] indegre = new int[numCourses]; // 记录每个节点的入度
        List<Integer>[] g = new ArrayList[numCourses];
        for(int i = 0; i < numCourses; i++) g[i] = new ArrayList<>();
        // 建图,获取每个节点的入度
        for(int[] p : prerequisites){
            indegre[p[0]]++; // p1 -> p0 有条边
            g[p[1]].add(p[0]);
        }
        // 队列里存放入度为0的节点
        Queue<Integer> queue = new LinkedList<>();
        for(int i = 0; i < numCourses; i++){
            if(indegre[i] == 0) queue.add(i);
        }
        // BFS遍历过程
        while(!queue.isEmpty()){
            int pre = queue.poll();
            numCourses--;
            for(int nxt : g[pre]){
                // 如果减去pre入度后的节点 入度为0,则可以执行该课程
                if(--indegre[nxt] == 0) queue.add(nxt);
            }
        }
        return numCourses == 0; // 判断是否所有课程都学习了
    }
}
  • 2
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值