21天算法训练营

下文为stormgzhang21天算法挑战学习笔记

前言 常用数据结构定义

01-单链表

public class ListNode {
    public int val;
    public ListNode next;

    public ListNode(int x) {
        val = x;
        next = null;
    }
}

02-二叉树结点

public class TreeNode {
    public int val;
    public TreeNode left;
    public TreeNode right;

    public TreeNode() {
    }

    public TreeNode(int val) {
        this.val = val;
    }

    public TreeNode(int val, TreeNode left, TreeNode right) {
        this.val = val;
        this.left = left;
        this.right = right;
    }
}

03-带Next属性的二叉树结点

public class Node {
    public int val;
    public Node left;
    public Node right;
    public Node next;

    public Node() {}

    public Node(int _val) {
        val = _val;
    }

    public Node(int _val, Node _left, Node _right, Node _next) {
        val = _val;
        left = _left;
        right = _right;
        next = _next;
    }
};

day01 双指针

day01中是两道很基础的快慢指针题

01-删除有序数组中的重复项

快慢指针;简单题

import org.junit.Test;

// https://leetcode-cn.com/problems/remove-duplicates-from-sorted-array/

public class RemoveDuplicatesCase {
    int removeDuplicates(int[] nums) {
        if (nums == null || nums.length == 0) {
            return 0;
        }
        int slow = 0, fast = 0;
        for (fast = slow + 1; fast < nums.length; fast++) {
            if (nums[fast] != nums[slow]) {
                nums[++slow] = nums[fast];
            }
        }
        return slow + 1;
    }

    @Test
    public void test() {
        int[] nums = {1, 1, 2};
//        int[] nums = {0, 0, 1, 1, 1, 2, 2, 3, 3, 4};
        System.out.println(removeDuplicates(nums));
    }
}

02-移除元素

快慢指针;简单题

import org.junit.Test;

// https://leetcode-cn.com/problems/remove-element/

public class RemoveElementCase {
    int removeElement(int[] nums, int val) {
        if (nums == null || nums.length == 0) {
            return 0;
        }
        int slow = 0, fast = 0;
        while (fast < nums.length) {
            if (nums[fast] != val) {
                nums[slow++] = nums[fast];
            }
            fast++;
        }
        return slow;
    }

    @Test
    public void test() {
        int[] nums = {0, 1, 2, 2, 3, 0, 4, 2};
        int var = 2;
        System.out.println(removeElement(nums, var));
    }
}

day02 前缀和技巧

前缀和技巧:用于求数组一段区间和的数据结构
时间复杂度:代码在最坏情况下的执行次数

01-区域和检索 - 数组不可变

数组nums的前缀和示例:
数组nums的前缀和

简单的前缀和应用

/**
 * https://leetcode-cn.com/problems/range-sum-query-immutable/
 */
public class NumArray {

    private int[] preSum;

    // 使用数组 nums 初始化对象
    public NumArray(int[] nums) {
        preSum = new int[nums.length + 1];
        for (int i = 1; i < preSum.length; i++) {
            preSum[i] = preSum[i - 1] + nums[i];
        }
    }

    public int sumRange(int left, int right) {
        return preSum[right+1] - preSum[left];
    }
}

02-二维区域和检索 - 矩阵不可变

二维数组的前缀和应用;画出表格有助于理解思路

import org.junit.Test;

/**
 * https://leetcode-cn.com/problems/range-sum-query-2d-immutable/
 */
public class NumMatrixCase {

    private int[][] preSum;

    public void NumMatrix(int[][] matrix) {
        int h = matrix.length + 1;
        int w = matrix[0].length + 1;
        preSum = new int[h][w];
        for (int i = 1; i < h; i++) {
            for (int j = 1; j < w; j++) {
                preSum[i][j] = matrix[i - 1][j - 1] + preSum[i - 1][j] + preSum[i][j - 1] - preSum[i - 1][j - 1];
            }
        }
    }

    public int sumRegion(int row1, int col1, int row2, int col2) {
        // 画表格理解
        return preSum[row2 + 1][col2 + 1] - preSum[row1][col2 + 1] - preSum[row2 + 1][col1] + preSum[row1][col1];
    }

    @Test
    public void test1() {
        int[][] matrix = {{3, 0, 1, 4, 2}, {5, 6, 3, 2, 1}, {1, 2, 0, 1, 5}, {4, 1, 0, 1, 7}, {1, 0, 3, 0, 5}};
        NumMatrix(matrix);
        System.out.println(sumRegion(2, 1, 4, 3));
    }
}

03-剑指 Offer II 010. 和为 k 的子数组

一维数组的前缀和应用,二重循环遍历前缀和数组

 * https://leetcode-cn.com/problems/QTMn0o/
 */
public class subarraySumCase {
    public int subarraySum(int[] nums, int k) {
        int len = nums.length;
        int[] preSum = new int[len + 1];
        for (int i = 1; i < len + 1; i++) {
            preSum[i] = nums[i - 1] + preSum[i - 1];
        }
        int ans = 0;
        for (int i = 1; i < len + 1; i++) {
            for (int j = 0; j < i; j++) {
                if (preSum[i] - preSum[j] == k) {
                    ans++;
                }
            }
        }
        return ans;
    }
}

day03 差分数组技巧

前文写过的前缀和技巧是非常常用的算法技巧,前缀和主要适用的场景是原始数组不会被修改的情况下,频繁查询某个区间的累加和。

本文讲一个和前缀和思想非常类似的算法技巧「差分数组」,差分数组的主要适用场景是频繁对原始数组的某个区间的元素进行增减。

01-差分数组

数组nums的差分数组示例:
查分数组示例

差分数组实现

import java.util.Arrays;

/**
 * 差分数组
 */
public class Difference {
    private int[] diff;

    // 构造查分数组
    public Difference(int[] nums) {
        this.diff = new int[nums.length];
        // diff[0] = nums[1] - 0
        this.diff[0] = nums[0];
        for (int i = 1; i < nums.length; i++) {
            // diff[i] = nums[i] - nums[i - 1] => 相邻两数之差
            this.diff[i] = nums[i] - nums[i - 1];
        }
    }

    // 增加数组
    public void increment(int i, int j, int val) {
        this.diff[i] = this.diff[i] + val;
        if (j + 1 < diff.length) {
            this.diff[j + 1] = this.diff[j + 1] - val;
        }
    }

    // 返回原始数组
    public int[] result() {
        int[] nums = new int[this.diff.length];
        nums[0] = this.diff[0];
        for (int i = 1; i < this.diff.length; i++) {
            nums[i] = nums[i - 1] + this.diff[i];
        }
        return nums;
    }
    
    public static void main(String[] args) {

        int[] nums = {8, 2, 6, 3, 1};
        Difference diff = new Difference(nums);
        System.out.println(Arrays.toString(diff.diff));
    }
}

02-区间加法

差分数组题目描述

使用构造的差分数组

import org.junit.Test;

/**
 * https://leetcode-cn.com/problems/range-sum-query-2d-immutable/
 */
public class NumMatrixCase {

    private int[][] preSum;

    public void NumMatrix(int[][] matrix) {
        int h = matrix.length + 1;
        int w = matrix[0].length + 1;
        preSum = new int[h][w];
        for (int i = 1; i < h; i++) {
            for (int j = 1; j < w; j++) {
                preSum[i][j] = matrix[i - 1][j - 1] + preSum[i - 1][j] + preSum[i][j - 1] - preSum[i - 1][j - 1];
            }
        }
    }

    public int sumRegion(int row1, int col1, int row2, int col2) {
        // 画表格理解
        return preSum[row2 + 1][col2 + 1] - preSum[row1][col2 + 1] - preSum[row2 + 1][col1] + preSum[row1][col1];
    }

    @Test
    public void test1() {
        int[][] matrix = {{3, 0, 1, 4, 2}, {5, 6, 3, 2, 1}, {1, 2, 0, 1, 5}, {4, 1, 0, 1, 7}, {1, 0, 3, 0, 5}};
        NumMatrix(matrix);
        System.out.println(sumRegion(2, 1, 4, 3));
    }
}

03-拼车

中等;对一个初始全为 0 的数组进行区间操作嘛,使用差分模板

/*
 * https://leetcode-cn.com/problems/car-pooling/
 */
import org.junit.Test;

/**
 * https://leetcode-cn.com/problems/car-pooling/
 * 参考答案: https://leetcode-cn.com/problems/car-pooling/solution/1094-pin-che-python-chai-fen-shu-zu-by-f-yvh7/
 */
public class CarPoolingCase {

    private int[] diff;

    // 增加数组
    public void increment(int i, int j, int val) {
        this.diff[i] = this.diff[i] + val;
        if (j + 1 < diff.length) {
            this.diff[j + 1] = this.diff[j + 1] - val;
        }
    }

    // 检测一段区间
    public boolean judge(int end, int capacity) {
        int[] nums = new int[end + 1];
        nums[0] = this.diff[0];
        if (nums[0] > capacity){
            return false;
        }
        for (int i = 1; i < end + 1; i++) {
            nums[i] = nums[i - 1] + this.diff[i];
            if (nums[i] > capacity) {
                return false;
            }
        }
        return true;
    }


    public boolean carPooling(int[][] trips, int capacity) {
        // 构造[0-1000]的差分数组(初始数据全0)
        this.diff = new int[1001];
        int end = trips[trips.length-1][2];
        // 添加下车的逻辑
        for (int[] trip : trips) {
            // 修改差分数组(下站的人数不增加)
            increment(trip[1], trip[2]-1, trip[0]);
            if (end < trip[2]){
                end = trip[2];
            }
        }
        // 只需要构造一次原数组
        return judge(end,capacity);
    }

    @Test
    public void test(){
        int [][]trips = {{9,0,1},{3,3,7}};
        int capacity = 4;
        // false

        System.out.println(carPooling(trips, capacity));
    }
}

04-航班预订统计

中等;完全无脑的差分数组

/*
 * https://leetcode-cn.com/problems/car-pooling/
 */
import org.junit.Test;

import java.util.Arrays;

/**
 * https://leetcode-cn.com/problems/corporate-flight-bookings/
 */
public class CorporateFlightBookingsCase {

    private int[] diff;

    // 增加数组
    public void increment(int i, int j, int val) {
        this.diff[i] = this.diff[i] + val;
        if (j + 1 < diff.length) {
            this.diff[j + 1] = this.diff[j + 1] - val;
        }
    }

    // 返回原始数组
    public int[] result() {
        int[] nums = new int[this.diff.length];
        nums[0] = this.diff[0];
        for (int i = 1; i < this.diff.length; i++) {
            nums[i] = nums[i - 1] + this.diff[i];
        }
        return nums;
    }

    public int[] corpFlightBookings(int[][] bookings, int n) {
        diff = new int[n];
        for (int[] booking : bookings) {
            increment(booking[0] - 1, booking[1] - 1, booking[2]);
        }
        return result();
    }

    @Test
    public void test() {
        int[][] updates = {{1, 2, 10}, {2, 3, 20}, {2, 5, 25}};
        int n = 5;
        System.out.println(Arrays.toString(corpFlightBookings(updates, n)));
    }
}

day04 回文串

回文串就是正着读和反着读都一样的字符串

01-最长回文子串

寻找回文串的问题核心思想是:从中间开始向两边扩散来判断回文串。

import org.junit.Test;

/**
 * https://leetcode-cn.com/problems/longest-palindromic-substring/
 */
public class LongestPalindromeCase {

    public String palindrome(int l, int r, String s) {
        while (l >= 0 && r < s.length() && s.charAt(l) == s.charAt(r)) {
            l--;
            r++;
        }
        return s.substring(l + 1, r);
    }

    // 双指针解决回文串
    public String longestPalindrome(String s) {
        String ans = "";
        // 以 i 为中心搜索
        for (int i = 0; i < s.length(); i++) {
            String temp = palindrome(i, i, s);
            ans = ans.length() > temp.length() ? ans : temp;
            temp = palindrome(i, i + 1, s);
            ans = ans.length() > temp.length() ? ans : temp;
        }
        return ans;
    }

    @Test
    public void test() {
        String s = "babad";
//        String s = "cbbd";
        System.out.println(longestPalindrome(s));
    }
}

day05 二分搜索技巧(基础)

二分查找的思想很简单,但实现细节很容易出错;内容出处来自labuladong大佬

import java.util.Arrays;
// 使用 API 实现二分查找
public class SearchCase {
    public int search(int[] nums, int target) {
        int index = Arrays.binarySearch(nums,target);
        return index >= 0 ? index : -1;
    }
}

01-二分查找

分析二分查找的一个技巧是:不要出现 else,而是把所有情况用 else if 写清楚,这样可以清楚地展现所有细节。

计算 mid 时需要防止溢出,代码中 left + (right - left) / 2 就和 (left + right) / 2 的结果相同,但是有效防止了 left 和 right 太大直接相加导致溢出。

二分查找:带有 … 的是需要注意的细节

int binarySearch(int[] nums, int target) {
    int left = 0, right = ...;

    while(...) {
        int mid = left + (right - left) / 2;
        if (nums[mid] == target) {
            ...
        } else if (nums[mid] ‹ target) {
            left = ...
        } else if (nums[mid] › target) {
            right = ...
        }
    }
    return ...;
}
1.1 寻找一个数(基本的二分搜索)
int binarySearch(int[] nums, int target) {
    int left = 0; 
    int right = nums.length - 1; // 注意

    while(left ‹= right) {
        int mid = left + (right - left) / 2;
        if(nums[mid] == target)
            return mid; 
        else if (nums[mid] ‹ target)
            left = mid + 1; // 注意
        else if (nums[mid] › target)
            right = mid - 1; // 注意
    }
    return -1;
}

1、为什么 while 循环的条件中是 ‹=,而不是 ‹ ?

搜索区间为空的时候应该终止。 while(left ‹= right) 的终止条件是 left == right + 1,写成区间的形式就是 [right + 1, right],或者带个具体的数字进去 [3, 2],可见这时候区间为空,因为没有数字既大于等于 3 又小于等于 2 的吧。所以这时候 while 循环终止是正确的。写成 while(left ‹ right) 打个补丁也可以。

2 、为什么 left = mid + 1,right = mid - 1?我看有的代码是 right = mid 或者 left = mid,没有这些加加减减,到底怎么回事,怎么判断?

当我们发现索引 mid 不是要找的 target 时,下一步应该去搜索哪里呢?当然是去搜索 [left, mid-1] 或者 [mid+1, right] 对不对?因为 mid 已经搜索过,应该从搜索区间中去除。

3、 此算法有什么缺陷?

比如说给你有序数组 nums = [1,2,2,2,3],target 为 2,此算法返回的索引是 2,没错。但是如果我想得到 target 的左侧边界,即索引 1,或者我想得到 target 的右侧边界,即索引 3,这样的话此算法是无法处理的。

1.2 寻找左侧边界的二分搜索
int left_bound(int[] nums, int target) {
    if (nums.length == 0) return -1;
    int left = 0;
    int right = nums.length; // 注意
    
    while (left ‹ right) { // 注意
        int mid = left + (right - left) / 2;
        if (nums[mid] == target) {
            right = mid;
        } else if (nums[mid] ‹ target) {
            left = mid + 1;
        } else if (nums[mid] › target) {
            right = mid; // 注意
        }
    }
    return left;
}

1、为什么 while 循环的条件中是 ‹,而不是 ‹= ?

因为 right = nums.length 而不是 nums.length - 1。因此每次循环的「搜索区间」是 [left, right) 左闭右开。while(left ‹ right) 终止的条件是 left == right,此时搜索区间 [left, left) 为空,所以可以正确终止。

2、为什么没有返回 -1 的操作?如果 nums 中不存在 target 这个值,怎么办?

函数的返回值(即 left 变量的值)取值区间是闭区间 [0, nums.length],所以我们简单添加两行代码就能在正确的时候 return -1:

while (left ‹ right) {
    //...
}
// target 比所有数都大
if (left == nums.length) return -1;
// 类似之前算法的处理方式
return nums[left] == target ? left : -1;

3、为什么 left = mid + 1,right = mid ?和之前的算法不一样?

因为我们的「搜索区间」是 [left, right) 左闭右开,所以当 nums[mid] 被检测之后,下一步的搜索区间应该去掉 mid 分割成两个区间,即 [left, mid) 或 [mid + 1, right);

4、为什么该算法能够搜索左侧边界?

关键在于对于 nums[mid] == target 这种情况的处理:

if (nums[mid] == target)
    right = mid;

可见,找到 target 时不要立即返回,而是缩小「搜索区间」的上界 right,在区间 [left, mid) 中继续搜索,即不断向左收缩,达到锁定左侧边界的目的。

5、为什么返回 left 而不是 right?

都是一样的,因为 while 终止的条件是 left == right。

6、能不能想办法把 right 变成 nums.length - 1,也就是继续使用两边都闭的「搜索区间」?
这样就可以和第一种二分搜索在某种程度上统一起来了。

当然可以,只要你明白了「搜索区间」这个概念,就能有效避免漏掉元素,随便你怎么改都行。下面我们严格根据逻辑来修改:
因为你非要让搜索区间两端都闭,所以 right 应该初始化为 nums.length - 1,while 的终止条件应该是 left == right + 1,也就是其中应该用 ‹=:

int left_bound(int[] nums, int target) {
    // 搜索区间为 [left, right]
    int left = 0, right = nums.length - 1;
    while (left ‹= right) {
        int mid = left + (right - left) / 2;
        // if else ...
    }

因为搜索区间是两端都闭的,且现在是搜索左侧边界,所以 left 和 right 的更新逻辑如下:

if (nums[mid] ‹ target) {
    // 搜索区间变为 [mid+1, right]
    left = mid + 1;
} else if (nums[mid] › target) {
    // 搜索区间变为 [left, mid-1]
    right = mid - 1;
} else if (nums[mid] == target) {
    // 收缩右侧边界
    right = mid - 1;
}

由于 while 的退出条件是 left == right + 1,所以当 target 比 nums 中所有元素都大时,会存在以下情况使得索引越界:
索引越界示意图
因此,最后返回结果的代码应该检查越界情况:

if (left ›= nums.length || nums[left] != target)
    return -1;
return left;

至此,整个算法就写完了,完整代码如下:

int left_bound(int[] nums, int target) {
    int left = 0, right = nums.length - 1;
    // 搜索区间为 [left, right]
    while (left ‹= right) {
        int mid = left + (right - left) / 2;
        if (nums[mid] ‹ target) {
            // 搜索区间变为 [mid+1, right]
            left = mid + 1;
        } else if (nums[mid] › target) {
            // 搜索区间变为 [left, mid-1]
            right = mid - 1;
        } else if (nums[mid] == target) {
            // 收缩右侧边界
            right = mid - 1;
        }
    }
    // 检查出界情况
    if (left ›= nums.length || nums[left] != target)
        return -1;
    return left;
}

这样就和第一种二分搜索算法统一了,都是两端都闭的「搜索区间」,而且最后返回的也是 left 变量的值。只要把住二分搜索的逻辑,两种形式大家看自己喜欢哪种记哪种吧。

1.3 寻找右侧边界的二分查找
int right_bound(int[] nums, int target) {
    if (nums.length == 0) return -1;
    int left = 0, right = nums.length;
    
    while (left ‹ right) {
        int mid = left + (right - left) / 2;
        if (nums[mid] == target) {
            left = mid + 1; // 注意
        } else if (nums[mid] ‹ target) {
            left = mid + 1;
        } else if (nums[mid] › target) {
            right = mid;
        }
    }
    return left - 1; // 注意
}

1、为什么这个算法能够找到右侧边界?

关键点在这里:

if (nums[mid] == target) {
    left = mid + 1;
}

当 nums[mid] == target 时,不要立即返回,而是增大「搜索区间」的下界 left,使得区间不断向右收缩,达到锁定右侧边界的目的。

2、为什么最后返回 left - 1 而不像左侧边界的函数,返回 left?而且我觉得这里既然是搜索右侧边界, 应该返回 right 才对。

首先,while 循环的终止条件是 left == right,所以 left 和 right 是一样的,你非要体现右侧的特点,返回 right - 1 好了。
至于为什么要减一,这是搜索右侧边界的一个特殊点,关键在这个条件判断:

if (nums[mid] == target) {
    left = mid + 1;
    // 这样想: mid = left - 1
}

右侧边界的特殊性

因为我们对 left 的更新必须是 left = mid + 1,就是说 while 循环结束时,nums[left] 一定不等于 target 了,而 nums[left-1] 可能是 target。
至于为什么 left 的更新必须是 left = mid + 1,同左侧边界搜索,就不再赘述。

3、为什么没有返回 -1 的操作?如果 nums 中不存在 target 这个值,怎么办?

类似之前的左侧边界搜索,因为 while 的终止条件是 left == right,就是说 left 的取值范围是 [0, nums.length],所以可以添加两行代码,正确地返回 -1:

while (left ‹ right) {
    // ...
}
if (left == 0) return -1;
return nums[left-1] == target ? (left-1) : -1;

4、是否也可以把这个算法的「搜索区间」也统一成两端都闭的形式呢?这样这三个写法就完全统一了,以后就可以闭着眼睛写出来了。

当然可以,类似搜索左侧边界的统一写法,其实只要改两个地方就行了:

int right_bound(int[] nums, int target) {
    int left = 0, right = nums.length - 1;
    while (left ‹= right) {
        int mid = left + (right - left) / 2;
        if (nums[mid] ‹ target) {
            left = mid + 1;
        } else if (nums[mid] › target) {
            right = mid - 1;
        } else if (nums[mid] == target) {
            // 这里改成收缩左侧边界即可
            left = mid + 1;
        }
    }
    // 这里改为检查 right 越界的情况,见下图
    if (right ‹ 0 || nums[right] != target)
        return -1;
    return right;
}

当 target 比所有元素都小时,right 会被减到 -1,所以需要在最后防止越界:
防止越界的图示

1.4 逻辑统一

来梳理一下这些细节差异的因果逻辑:

第一个,最基本的二分查找算法:

因为我们初始化 right = nums.length - 1
所以决定了我们的「搜索区间」是 [left, right]
所以决定了 while (left ‹= right)

同时也决定了 left = mid+1 和 right = mid-1
因为我们只需找到一个 target 的索引即可
所以当 nums[mid] == target 时可以立即返回

第二个,寻找左侧边界的二分查找:

因为我们初始化 right = nums.length
所以决定了我们的「搜索区间」是 [left, right)
所以决定了 while (left ‹ right)
同时也决定了 left = mid + 1 和 right = mid

因为我们需找到 target 的最左侧索引
所以当 nums[mid] == target 时不要立即返回
而要收紧右侧边界以锁定左侧边界

第三个,寻找右侧边界的二分查找:

因为我们初始化 right = nums.length
所以决定了我们的「搜索区间」是 [left, right)
所以决定了 while (left ‹ right)
同时也决定了 left = mid + 1 和 right = mid

因为我们需找到 target 的最右侧索引
所以当 nums[mid] == target 时不要立即返回
而要收紧左侧边界以锁定右侧边界

又因为收紧左侧边界时必须 left = mid + 1
所以最后无论返回 left 还是 right,必须减一

对于寻找左右边界的二分搜索,常见的手法是使用左闭右开的「搜索区间」,我们还根据逻辑将「搜索区间」全都统一成了两端都闭,便于记忆,只要修改两处即可变化出三种写法:

int binary_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) {
            left = mid + 1;
        } else if (nums[mid] › target) {
            right = mid - 1; 
        } else if(nums[mid] == target) {
            // 直接返回
            return mid;
        }
    }
    // 直接返回
    return -1;
}

int left_bound(int[] nums, int target) {
    int left = 0, right = nums.length - 1;
    while (left ‹= right) {
        int mid = left + (right - left) / 2;
        if (nums[mid] ‹ target) {
            left = mid + 1;
        } else if (nums[mid] › target) {
            right = mid - 1;
        } else if (nums[mid] == target) {
            // 别返回,锁定左侧边界
            right = mid - 1;
        }
    }
    // 最后要检查 left 越界的情况
    if (left ›= nums.length || nums[left] != target)
        return -1;
    return left;
}


int right_bound(int[] nums, int target) {
    int left = 0, right = nums.length - 1;
    while (left ‹= right) {
        int mid = left + (right - left) / 2;
        if (nums[mid] ‹ target) {
            left = mid + 1;
        } else if (nums[mid] › target) {
            right = mid - 1;
        } else if (nums[mid] == target) {
            // 别返回,锁定右侧边界
            left = mid + 1;
        }
    }
    // 最后要检查 right 越界的情况
    if (right ‹ 0 || nums[right] != target)
        return -1;
    return right;
}

二分搜索算法的核心逻辑:

  1. 分析二分查找代码时,不要出现 else,全部展开成 else if 方便理解。
  2. 注意「搜索区间」和 while 的终止条件,如果存在漏掉的元素,记得在最后检查。
  3. 如需定义左闭右开的「搜索区间」搜索左右边界,只要在 nums[mid] == target 时做修改即可,搜索右侧时需要减一。
  4. 如果将「搜索区间」全都统一成两端都闭,好记,只要稍改 nums[mid] == target 条件处的代码和返回的逻辑即可。

02-在排序数组中查找元素的第一个和最后一个位置

/**
 * https://leetcode-cn.com/problems/find-first-and-last-position-of-element-in-sorted-array/
 */
public class SearchRangeCase {
    public int[] searchRange(int[] nums, int target) {
        int[] ans = {-1, -1};
        ans[0] = left_bound(nums, target);
        ans[1] = right_bound(nums, target);
        return ans;
    }

    public int left_bound(int[] nums, int target) {
        if (nums == null || nums.length == 0) {
            return -1;
        }
        int left = 0, right = nums.length - 1;
        // left == right 时是有元素的
        while (left <= right) {
            int mid = left + (right - left) / 2;
            if (nums[mid] == target) {
                // 向左侧继续搜索
                right = mid - 1;
            } else if (nums[mid] > target) {
                right = mid - 1;
            } else if (nums[mid] < target) {
                left = mid + 1;
            }
        }
        if (left >= nums.length || nums[left] != target) {
            return -1;
        }
        return left;
    }

    public int right_bound(int[] nums, int target) {
        if (nums == null || nums.length == 0) {
            return -1;
        }
        int left = 0, right = nums.length - 1;
        // left == right 时是有元素的
        while (left <= right) {
            int mid = left + (right - left) / 2;
            if (nums[mid] == target) {
                // 向由右侧继续搜索
                left = mid + 1;
            } else if (nums[mid] > target) {
                right = mid - 1;
            } else if (nums[mid] < target) {
                left = mid + 1;
            }
        }
        if (right < 0 || nums[left - 1] != target) {
            return -1;
        }
        return left - 1;
    }
}

day06 二分搜索技巧(运用)

怎样运用二分查找解决问题;内容出处来自labuladong大佬

01-运用二分搜索的套路框架

想要运用二分搜索解决具体的算法问题,可以从以下代码框架着手思考:

// 函数 f 是关于自变量 x 的单调函数
int f(int x) {
    // ...
}

// 主函数,在 f(x) == target 的约束下求 x 的最值
int solution(int[] nums, int target) {
    if (nums.length == 0) return -1;
    // 问自己:自变量 x 的最小值是多少?
    int left = ...;
    // 问自己:自变量 x 的最大值是多少?
    int right = ... + 1;
    
    while (left ‹ right) {
        int mid = left + (right - left) / 2;
        if (f(mid) == target) {
            // 问自己:题目是求左边界还是右边界?
            // ...
        } else if (f(mid) ‹ target) {
            // 问自己:怎么让 f(x) 大一点?
            // ...
        } else if (f(mid) › target) {
            // 问自己:怎么让 f(x) 小一点?
            // ...
        }
    }
    return left;
}

具体来说,想要用二分搜索算法解决问题,分为以下几步:

  1. 确定 x, f(x), target 分别是什么,并写出函数 f 的代码。
  2. 找到 x 的取值范围作为二分搜索的搜索区间,初始化 left 和 right 变量。
  3. 根据题目的要求,确定应该使用搜索左侧还是搜索右侧的二分搜索算法,写出解法代码。

02-爱吃香蕉的珂珂

1、确定 x, f(x), target 分别是什么,并写出函数 f 的代码。

自变量 x 是什么呢? 回忆之前的函数图像,二分搜索的本质就是在搜索自变量。所以,题目让求什么,就把什么设为自变量,珂珂吃香蕉的速度就是自变量 x。

那么,在 x 上单调的函数关系 f(x) 是什么? 显然,吃香蕉的速度越快,吃完所有香蕉堆所需的时间就越少,速度和时间就是一个单调函数关系。所以,f(x) 函数就可以这样定义:若吃香蕉的速度为 x 根/小时,则需要 f(x) 小时吃完所有香蕉。

代码实现如下:

// 定义:速度为 x 时,需要 f(x) 小时吃完所有香蕉
// f(x) 随着 x 的增加单调递减
int f(int[] piles, int x) {
    int hours = 0;
    for (int i = 0; i ‹ piles.length; i++) {
        hours += piles[i] / x;
        if (piles[i] % x › 0) {
            hours++;
        }
    }
    return hours;
}

target是什么呢? target 就很明显了,吃香蕉的时间限制 H 自然就是 target,是对 f(x) 返回值的最大约束。


2、找到 x 的取值范围作为二分搜索的搜索区间,初始化 left 和 right 变量。

珂珂吃香蕉的速度最小是多少?多大是多少? 显然,最小速度应该是 1,最大速度是 piles 数组中元素的最大值, 因为每小时最多吃一堆香蕉,胃口再大也白搭嘛。这里可以有两种选择,要么你用一个 for 循环去遍历 piles 数组,计算最大值,要么你看题目给的约束,piles 中的元素取值范围是多少,然后给 right 初始化一个取值范围之外的值。我选择第二种,题目说了 1 ‹= piles[i] ‹= 10^9,那么我就可以确定二分搜索的区间边界:

public int minEatingSpeed(int[] piles, int H) {
    int left = 1;
    // 注意,right 是开区间,所以再加一
    int right = 1000000000 + 1;

    // ...
}

3、根据题目的要求,确定应该使用搜索左侧还是搜索右侧的二分搜索算法,写出解法代码。

现在我们确定了自变量 x 是吃香蕉的速度,f(x) 是单调递减的函数,target 就是吃香蕉的时间限制 H,题目要我们计算最小速度,也就是 x 要尽可能小:趋势图
结合上图:

public int minEatingSpeed(int[] piles, int H) {
    int left = 1;
    int right = 1000000000 + 1;
    
    while (left ‹ right) {
        int mid = left + (right - left) / 2;
        if (f(piles, mid) == H) {
            // 搜索左侧边界,则需要收缩右侧边界
            right = mid;
        } else if (f(piles, mid)H) {
            // 需要让 f(x) 的返回值大一些
            right = mid;
        } else if (f(piles, mid)H) {
            // 需要让 f(x) 的返回值小一些
            left = mid + 1;
        }
    }
    return left;
}

最终答案

/**
 * https://leetcode-cn.com/problems/koko-eating-bananas/
 * 套用二分查找
 */
public class MinEatingSpeedCase {

    // 每小时吃 x 根香蕉最终所需的时间
    public int f(int[] piles, int x) {
        int hours = 0;
        for (int pile : piles) {
            hours = hours + pile / x;
            if (pile % x > 0) {
                hours++;
            }
        }
        return hours;
    }
    
    public int minEatingSpeed(int[] piles, int h) {
        if (piles == null || piles.length == 0) {
            return 0;
        }
        int left = 1, right = 1000000001;
        while (left <= right) {
            int mid = left + (right - left) / 2;
            if (f(piles, mid) == h) {
                // 向右收缩
                right = mid - 1;
            } else if (f(piles, mid) < h) {
                right = mid - 1;
            } else if (f(piles, mid) > h) {
                left = mid + 1;
            }
        }
        return left;
    }
}

03-在 D 天内送达包裹的能力

套用二分查找

import org.junit.Test;
/**
 * https://leetcode-cn.com/problems/capacity-to-ship-packages-within-d-days/
 */
public class ShipWithinDaysCase {

    public int fun(int[] weights, int d) {
        int days = 0, index = 0;

        while (index < weights.length) {
            int temp = d;
            // 能装则装
            while (index < weights.length && (temp = temp - weights[index]) >= 0) {
                index++;
            }
            // 装不下了就运走天数 +1
            days++;
        }
        return days;
    }

    public int shipWithinDays(int[] weights, int days) {
        if (weights == null || weights.length == 0) {
            return -1;
        }
        int left = Integer.MIN_VALUE, right = 0;
        for (int weight : weights) {
            if (weight > left) {
                left = weight;
            }
            right += weight;
        }
        while (left <= right) {
            int mid = left + (right - left) / 2;
            if (fun(weights, mid) == days) {
                right = mid - 1;
            } else if (fun(weights, mid) > days) {
                left = mid + 1;
            } else if (fun(weights, mid) < days) {
                right = mid - 1;
            }
        }
        return left;
    }

    @Test
    public void test() {
        int[] weights = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
        int days = 5;
        System.out.println(shipWithinDays(weights, days));
    }
}

day07 滑动窗口技巧【选学】

滑动窗口技巧: 就是维护一个窗口,不断滑动,然后更新答案;大致的算法逻辑:

int left = 0, right = 0;
while (right < left){
    window.add(s[right]);
    right++;

    while (window needs shrink){
        window.remove(s[left]);
        left++;
    }
}

滑动窗口代码模板

import java.util.HashMap;
import java.util.Map;

// 滑动窗口模板
public class SlidingWindowTemplate {
    /* 滑动窗口算法框架 => 解决两个字符串的公共子串类似的问题 */
    void slidingWindow(String s, String t) {
        Map<Character, Integer> need = new HashMap<>(), window = new HashMap<>();
        for (int i = 0; i < t.length(); i++) {
            // 获取当前字符
            char key = t.charAt(i);
            need.put(key, need.getOrDefault(key, 0) + 1);
        }
        // 左右指针
        int left = 0, right = 0;
        // 合法值
        int valid = 0;
        while (right < s.length()) {
            // c 是将移入窗口的字符
            Character c = s.charAt(right);
            // 右移窗口
            right++;
            // 进行窗口内一系列数据的更新
            // ...

            /*** debug 输出的位置 ***/
            System.out.println("window:[" + left + "," + right + "]");
            /********************/

            while (needsShrink(window)) {
                // d 是将移除窗口的
                Character d = s.charAt(left);
                // 左移窗口
                left++;
                // 窗口内的数据进行一系列的更新
                // ...
            }
        }
    }

    /* 判断是否需要收缩窗口 */
    public boolean needsShrink(Map<Character, Integer> window) {
        // ...
        return false;
    }
}

01-无重复字符的最长子串

这就是变简单了,连 need 和 valid 都不需要,而且更新窗口内数据也只需要简单的更新计数器 window 即可。当 window[c] 值大于 1 时,说明窗口中存在重复字符,不符合条件,就该移动 left 缩小窗口了嘛。在收缩窗口完成后更新 res,因为窗口收缩的 while 条件是存在重复元素,换句话说收缩完成后一定保证窗口中没有重复嘛。

import java.util.HashMap;
import java.util.Map;

/**
 * 无重复字符的最长子串
 * https://leetcode-cn.com/problems/longest-substring-without-repeating-characters/
 */
public class LengthOfLongestSubstringAns {
    public int lengthOfLongestSubstring(String s) {
        if (s == null || s.length() == 0) {
            return 0;
        }

        int ans = 0;
        int left = 0, right = 0;
        Map<Character, Integer> window = new HashMap<>();
        while (right < s.length()) {
            char c = s.charAt(right);
            right++;
            // 更新窗口数据
            window.put(c, window.getOrDefault(c, 0) + 1);
            // 判断左侧窗口是否要收缩 => window内出现元素重复
            while (window.get(c) > 1) {
                // 此时 c 和 d 相等
                char d = s.charAt(left);
                left++;
                // 进行窗口内一系列的更新(此时 c == d, d 一定存在)
                window.put(d, window.get(d) - 1);
            }
            ans = Math.max(ans, right - left);
        }
        return ans;
    }
}

02-最小覆盖子串

滑动窗口算法的思路是这样:

  1. 我们在字符串 S 中使用双指针中的左右指针技巧,初始化 left = right = 0,把索引左闭右开区间 [left, right) 称为一个「窗口」。
  2. 我们先不断地增加 right 指针扩大窗口 [left, right),直到窗口中的字符串符合要求(包含了 T 中的所有字符)。
  3. 此时,我们停止增加 right,转而不断增加 left 指针缩小窗口 [left, right),直到窗口中的字符串不再符合要求(不包含 T 中的所有字符了)。同时,每次增加 left,我们都要更新一轮结果。
  4. 重复第 2 和第 3 步,直到 right 到达字符串 S 的尽头。

这个思路其实也不难,第 2 步相当于在寻找一个「可行解」,然后第 3 步在优化这个「可行解」,最终找到最优解,也就是最短的覆盖子串。左右指针轮流前进,窗口大小增增减减,窗口不断向右滑动,这就是「滑动窗口」这个名字的来历。

下面画图理解一下,needs 和 window 相当于计数器,分别记录 T 中字符出现次数和「窗口」中的相应字符的出现次数。

初始状态:

初始状态

增加 right,直到窗口 [left, right] 包含了 T 中所有字符:

增加right

现在开始增加 left,缩小窗口 [left, right]。

增加left

直到窗口中的字符串不再符合要求,left 不再继续移动。

窗口中的字符串不再符合要求
之后重复上述过程,先移动 right,再移动 left…… 直到 right 指针到达字符串 S 的末端,算法结束。
如果你能够理解上述过程,恭喜,你已经完全掌握了滑动窗口算法思想。现在我们来看看这个滑动窗口代码框架怎么用:
首先,初始化 window 和 need 两个哈希表,记录窗口中的字符和需要凑齐的字符:

Map<Character, Integer> need = new HashMap<>(), window = new HashMap<>();
for (int i = 0; i < s1.length(); i++) {
    // 获取当前字符
    char key = s1.charAt(i);
    need.put(key, need.getOrDefault(key, 0) + 1);
}

然后,使用 left 和 right 变量初始化窗口的两端,不要忘了,区间 [left, right) 是左闭右开的,所以初始情况下窗口没有包含任何元素:

int left = 0, right = 0;
int valid = 0; 
while (right ‹ s.size()) {
    // 开始滑动
}

其中 valid 变量表示窗口中满足 need 条件的字符个数,如果 valid 和 need.size 的大小相同,则说明窗口已满足条件,已经完全覆盖了串 T。

现在开始套模板,只需要思考以下四个问题:

1、当移动 right 扩大窗口,即加入字符时,应该更新哪些数据?
2、什么条件下,窗口应该暂停扩大,开始移动 left 缩小窗口?
3、当移动 left 缩小窗口,即移出字符时,应该更新哪些数据?
4、我们要的结果应该在扩大窗口时还是缩小窗口时进行更新?

如果一个字符进入窗口,应该增加 window 计数器;如果一个字符将移出窗口的时候,应该减少 window 计数器;当 valid 满足 need 时应该收缩窗口;应该在收缩窗口的时候更新最终结果。

最终代码:

import org.junit.Test;

import java.util.HashMap;
import java.util.Map;

/*
最小覆盖子串
https://leetcode-cn.com/problems/minimum-window-substring/
 */
public class MinWindowCase {
    // s 中寻找涵盖 t 所有字符的子串,t 是短的
    public String minWindow(String s, String t) {
        Map<Character, Integer> window = new HashMap<>(), need = new HashMap<>();
        for (int i = 0; i < t.length(); i++) {
            char c = t.charAt(i);
            // 包含重复字符
            need.put(c, need.getOrDefault(c, 0) + 1);
        }
        int left = 0, right = 0;
        // 符合条件字符串的个数
        int valid = 0;
        // 记录最小覆盖子串的起始索引及长度
        int start = 0, len = Integer.MAX_VALUE;
        // [left, right)
        while (right < s.length()) {
            char c = s.charAt(right);
            // 扩大窗口
            right++;
            // 进行窗口内数据的一系列更新 => 如果新增字符在短串中
            if (need.containsKey(c)) {
                window.put(c, window.getOrDefault(c, 0) + 1);
                if (need.get(c).equals(window.get(c))) {
                    valid++;
                }

                // 判断左侧窗口是否要收缩 => 当 valid 满足 need 时应该收缩窗口
                while (valid == need.size()) {
                    // 在这里更新最小覆盖子串
                    if (right - left < len) {
                        start = left;
                        len = right - left;
                    }
                    // d 是将移出窗口的字符
                    char d = s.charAt(left);
                    // 左移窗口
                    left++;
                    // 进行窗口内数据的一系列更新
                    if (need.containsKey(d)) {
                        if (window.get(d).equals(need.get(d))) {
                            valid--;
                        }
                        window.put(d, window.get(d) - 1);
                    }

                }
            }
        }
        return len == Integer.MAX_VALUE ? "" : s.substring(start, start + len);
    }

    @Test
    public void test() {
        String s = "ADOBECODEBANC", t = "ABC";
        // "BANC"
        System.out.println(minWindow(s, t));
    }
}

03-字符串的排列

输入的 s1 是可以包含重复字符的,所以这个题难度不小。这种题目,是明显的滑动窗口算法,相当给你一个 S 和一个 T,请问你 S 中是否存在一个子串,包含 T 中所有字符且不包含其他字符。

1、本题移动 left 缩小窗口的时机是窗口大小大于 t.size() 时,应为排列嘛,显然长度应该是一样的。
2、当发现 valid == need.size() 时,就说明窗口中就是一个合法的排列,所以立即返回 true。
至于如何处理窗口的扩大和缩小,和最小覆盖子串完全相同。

import org.junit.Test;

import java.util.HashMap;
import java.util.Map;

/**
 * 字符串的排列
 * https://leetcode-cn.com/problems/permutation-in-string/
 */
public class CheckInclusionCase {

    // 判断 s2 中是否存在 s1 的排列,s1 代表短的字符串
    public boolean checkInclusion(String s1, String s2) {
        Map<Character, Integer> need = new HashMap<>(), window = new HashMap<>();
        for (int i = 0; i < s1.length(); i++) {
            // 获取当前字符
            char key = s1.charAt(i);
            need.put(key, need.getOrDefault(key, 0) + 1);
        }

        int left = 0, right = 0;
        int valid = 0;
        while (right < s2.length()) {
            char c = s2.charAt(right);
            right++;
            // 进行窗口内数据的一系列更新
            if (need.containsKey(c)) {
                window.put(c, window.getOrDefault(c, 0) + 1);
                if (window.get(c).equals(need.get(c)))
                    valid++;
            }

            // 判断左侧窗口是否要收缩
            while (right - left >= s1.length()) {
                // 在这里判断是否找到了合法的子串
                if (valid == need.size()) {
                    return true;
                }
                char d = s2.charAt(left);
                left++;
                // 进行窗口内数据的一系列更新
                if (need.containsKey(d)) {
                    if (window.get(d).equals(need.get(d))) {
                        valid--;
                    }
                    window.put(d, window.get(d) - 1);
                }
            }
        }
        // 未找到符合条件的子串
        return false;
    }


    @Test
    public void test() {
        // String s1 = "ab", s2 = "eidboaoo";
        // false

        String s1 = "ab", s2 = "eidbaooo";
        // true
        System.out.println(checkInclusion(s1, s2));
    }
}

04-找到字符串中所有字母异位词

和最小子覆盖解题思路基本一致

import org.junit.Test;

import java.util.HashMap;
import java.util.Map;

/*
最小覆盖子串
https://leetcode-cn.com/problems/minimum-window-substring/
 */
public class MinWindowCase {
    // s 中寻找涵盖 t 所有字符的子串,t 是短的
    public String minWindow(String s, String t) {
        Map<Character, Integer> window = new HashMap<>(), need = new HashMap<>();
        for (int i = 0; i < t.length(); i++) {
            char c = t.charAt(i);
            // 包含重复字符
            need.put(c, need.getOrDefault(c, 0) + 1);
        }
        int left = 0, right = 0;
        // 符合条件字符串的个数
        int valid = 0;
        // 记录最小覆盖子串的起始索引及长度
        int start = 0, len = Integer.MAX_VALUE;
        // [left, right)
        while (right < s.length()) {
            char c = s.charAt(right);
            // 扩大窗口
            right++;
            // 进行窗口内数据的一系列更新 => 如果新增字符在短串中
            if (need.containsKey(c)) {
                window.put(c, window.getOrDefault(c, 0) + 1);
                if (need.get(c).equals(window.get(c))) {
                    valid++;
                }

                // 判断左侧窗口是否要收缩 => 当 valid 满足 need 时应该收缩窗口
                while (valid == need.size()) {
                    // 在这里更新最小覆盖子串
                    if (right - left < len) {
                        start = left;
                        len = right - left;
                    }
                    // d 是将移出窗口的字符
                    char d = s.charAt(left);
                    // 左移窗口
                    left++;
                    // 进行窗口内数据的一系列更新
                    if (need.containsKey(d)) {
                        if (window.get(d).equals(need.get(d))) {
                            valid--;
                        }
                        window.put(d, window.get(d) - 1);
                    }

                }
            }
        }
        return len == Integer.MAX_VALUE ? "" : s.substring(start, start + len);
    }

    @Test
    public void test() {
        String s = "ADOBECODEBANC", t = "ABC";
        // "BANC"
        System.out.println(minWindow(s, t));
    }
}

day08 链表技巧汇总

单链表有很多巧妙的操作,本文就来总结几个常规思维不容易想到的小技巧。

01-删除单链表的倒数第 k 个节点

使用前后双指针

前后双指针示意图

public ListNode removeNthFromEnd(ListNode head, int n) {
     if (head.next == null && n == 1) {
         return null;
     }
     ListNode p1 = head, p2 = head;
     for (int i = 0; i < n; i++) {
         if (p2 == null) {
             return null;
         }
         p2 = p2.next;
     }
     while (p2 != null && p2.next != null) {
         p1 = p1.next;
         p2 = p2.next;
     }
     // 如果要删除的结点就是头结点
     if (p1 == head && p2 == null) {
         return p1.next;
     }
     // 删除结点
     p1.next = p1.next.next;
     return head;
 }

02-单链表的中点

快慢指针,快指针走两步,慢指针走一步

ListNode middleNode(ListNode head) {
    // 快慢指针初始化指向 head
    ListNode slow = head, fast = head;
    // 快指针走到末尾时停止
    while (fast != null && fast.next != null) {
        // 慢指针走一步,快指针走两步
        slow = slow.next;
        fast = fast.next.next;
    }
    // 慢指针指向中点
    return slow;
}

03-判断链表是否包含环

快慢指针:每当慢指针 slow 前进一步,快指针 fast 就前进两步。

boolean hasCycle(ListNode head) {
    // 快慢指针初始化指向 head
    ListNode slow = head, fast = head;
    // 快指针走到末尾时停止
    while (fast != null && fast.next != null) {
        // 慢指针走一步,快指针走两步
        slow = slow.next;
        fast = fast.next.next;
        // 快慢指针相遇,说明含有环
        if (slow == fast) {
            return true;
        }
    }
    // 不包含环
    return false;
}

04-两个链表是否相交

解法一:使用栈求解

public ListNode getIntersectionNode(ListNode headA, ListNode headB) {
    ListNode p = null;
    Stack<ListNode> stackA = new Stack<>();
    for (ListNode node = headA; node != null; node = node.next) {
        stackA.push(node);
    }
    Stack<ListNode> stackB = new Stack<>();
    for (ListNode node = headB; node != null; node = node.next) {
        stackB.push(node);
    }
    while (!stackA.empty() && !stackB.empty() && stackA.peek() == stackB.peek()) {
        p = stackA.pop();
        stackB.pop();
    }
    return p;
}

解法二:双指针解法——让 p1 遍历完链表 A 之后开始遍历链表 B,让 p2 遍历完链表 B 之后开始遍历链表 A,这样相当于「逻辑上」两条链表接在了一起。

双指针解法图示

public ListNode getIntersectionNode(ListNode headA, ListNode headB) {
   // p1 指向 A 链表头结点,p2 指向 B 链表头结点
    ListNode p1 = headA, p2 = headB;
    while (p1 != p2) {
        // p1 走一步,如果走到 A 链表末尾,转到 B 链表
        if (p1 == null) p1 = headB;
        else            p1 = p1.next;
        // p2 走一步,如果走到 B 链表末尾,转到 A 链表
        if (p2 == null) p2 = headA;
        else            p2 = p2.next;
    }
    return p1;
}

解法三:还可以使用 Set 求解;代码略

05-环形链表 II

解法一 使用 Set 求解

/**
 * 142. 环形链表 II
 * https://leetcode-cn.com/problems/linked-list-cycle-ii/submissions/
 */
public class DetectCycleCase {
    public ListNode detectCycle(ListNode head) {
        Set<ListNode> set = new HashSet<>();
        for (ListNode node = head; node != null; node = node.next){
            if (set.contains(node)){
                return node;
            }else {
                set.add(node);
            }
        }
        return null;
    }
}

解法二 使用快慢指针求解;下图来自 labuladong 大佬

快慢指针图示

/**
 * 142. 环形链表 II
 * https://leetcode-cn.com/problems/linked-list-cycle-ii/submissions/
 */
public ListNode detectCycle(ListNode head) {
    ListNode fast, slow;
    fast = slow = head;
    while (fast != null && fast.next != null) {
        fast = fast.next.next;
        slow = slow.next;
        if (fast == slow) break;
    }
    // 上面的代码类似 hasCycle 函数
    if (fast == null || fast.next == null) {
        // fast 遇到空指针说明没有环
        return null;
    }

    // 重新指向头结点
    slow = head;
    // 快慢指针同步前进,相交点就是环起点
    while (slow != fast) {
        fast = fast.next;
        slow = slow.next;
    }
    return slow;
}

day09 队列和栈互转

使用队列实现栈以及使用栈实现队列

01-用栈实现队列

使用两个栈

import java.util.Stack;

/**
 * 用栈实现队列
 * https://leetcode-cn.com/problems/implement-queue-using-stacks/submissions/
 */
public class MyQueue {

    private final Stack<Integer> stackA;
    private final Stack<Integer> stackB;

    public MyQueue() {
        stackA = new Stack<>();
        stackB = new Stack<>();
    }

    public void push(int x) {
        stackA.push(x);
    }

    public int pop() {
        if (!stackB.empty()) {
            return stackB.pop();
        }
        while (!stackA.empty()) {
            stackB.push(stackA.pop());
        }
        return stackB.pop();
    }

    public int peek() {
        if (!stackB.empty()) {
            return stackB.peek();
        }
        while (!stackA.empty()) {
            stackB.push(stackA.pop());
        }
        return stackB.peek();
    }

    public boolean empty() {
        return stackA.empty() && stackB.empty();
    }
}

02-用队列实现栈

使用一个队列

import java.util.ArrayDeque;
import java.util.Queue;

/**
 * 用队列实现栈
 * https://leetcode-cn.com/problems/implement-stack-using-queues/
 */
public class MyStack {

    private final Queue<Integer> queue;
    private int top_elem;

    public MyStack() {
        queue = new ArrayDeque<>();
    }

    public void push(int x) {
        queue.add(x);
        top_elem = x;
    }

    public int pop() {
        int size = queue.size();
        while (size-- > 1){
            top_elem = queue.poll();
            queue.offer(top_elem);
        }
        return queue.poll();
    }

    public int top() {
        return top_elem;
    }

    public boolean empty() {
        return queue.isEmpty();
    }
}

day10 单调队列和单调栈

01-下一个更大元素 I

解法示意图

Next Greater Number 问题, 使用单调递减的单调栈

import org.junit.Test;

import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import java.util.Stack;

/**
 * 下一个更大元素 I
 * https://leetcode-cn.com/problems/next-greater-element-i/
 */
public class NextGreaterElementCase {
    public int[] nextGreaterElement(int[] nums1, int[] nums2) {
        Map<Integer, Integer> map = new HashMap<>();
        int[] ans = new int[nums1.length];
        // 使用单调栈记录数据
        Stack<Integer> stack = new Stack<>();
        for (int i = nums2.length - 1; i >= 0; i--) {
            // 栈顶元素小 => 出栈
            while (!stack.empty() && stack.peek() < nums2[i]) {
                stack.pop();
            }
            if (stack.empty() || stack.peek() <= nums2[i]) {
                map.put(nums2[i], -1);
            } else {
                map.put(nums2[i], stack.peek());
            }
            stack.push(nums2[i]);
        }
        for (int i = 0; i < ans.length; i++) {
            ans[i] = map.get(nums1[i]);
        }
        return ans;
    }

    @Test
    public void test() {
        int []nums1 = {4,1,2}, nums2 = {1,3,4,2};
        System.out.println(Arrays.toString(nextGreaterElement(nums1, nums2)));
    }
}

02-滑动窗口最大值

单调队列 :元素单调递增(或递减) 队列,此题为递减

在这里插入图片描述

import org.junit.Test;

import java.util.Arrays;
import java.util.LinkedList;

/**
 * 滑动窗口最大值
 * https://leetcode-cn.com/problems/sliding-window-maximum/
 */
public class MaxSlidingWindowCase {
    // 元素单调递增(或递减) 队列,此题为递减
    static class MonotonicQueue {

        private final LinkedList<Integer> list = new LinkedList<>();

        // 在队尾添加元素 n
        void push(Integer n) {
            // 压碎前面比自己小的元素
            while (!list.isEmpty() && list.getLast() < n) {
                list.pollLast();
            }
            // 添加到末尾
            list.addLast(n);
        }

        // 返回当前队列中的最大值
        Integer max() {
            return list.getFirst();
        }

        // 队头元素如果是 n,删除它(因为 n 可能已经被压碎了)
        void pop(Integer n) {
            if (n.equals(list.getFirst())) {
                list.pollFirst();
            }
        }
    }

    public int[] maxSlidingWindow(int[] nums, int k) {
        int[] ans = new int[nums.length - k + 1];
        int index = 0;
        MonotonicQueue queue = new MonotonicQueue();
        for (int i = 0; i < nums.length; i++) {
            // 将前 k - 1 个元素添加进队列
            if (i < k - 1) {
                queue.push(nums[i]);
            } else {
                queue.push(nums[i]);
                ans[index++] = queue.max();
                queue.pop(nums[i - k + 1]);
            }
        }
        return ans;
    }
    
    @Test
    public void test() {
        int[] temperatures = {1, 3, -1, -3, 5, 3, 6, 7};
        int k = 3;
        // [3,3,5,5,6,7]
        System.out.println(Arrays.toString(maxSlidingWindow(temperatures, k)));
    }
}

03-每日温度

利用带 <k,v> 单调栈求解

import org.junit.Test;

import java.util.Arrays;
import java.util.Stack;

/**
 * 每日温度
 * https://leetcode-cn.com/problems/daily-temperatures/
 */
public class DailyTemperaturesCase {

    class Entry<K, V> {
        K key;
        V value;

        public Entry(K key, V value) {
            this.key = key;
            this.value = value;
        }
    }

    public int[] dailyTemperatures(int[] temperatures) {
        // 直接利用单调栈求解
        int[] ans = new int[temperatures.length];
        // <k,v> => k 是元素, v 是索引
        Stack<Entry<Integer, Integer>> stack = new Stack<>();
        for (int i = temperatures.length - 1; i >= 0; i--) {
            // 向后寻找温度更高的天气
            while (!stack.empty() && stack.peek().key <= temperatures[i]) {
                stack.pop();
            }
            // 弹空了,说明之后没有温度更高的天气
            if (stack.empty()) {
                ans[i] = 0;
            } else {
                ans[i] = stack.peek().value - i;
            }
            stack.push(new Entry<Integer, Integer>(temperatures[i], i));
        }
        return ans;
    }

    @Test
    public void test() {
        int[] temperatures = {73, 74, 75, 71, 69, 72, 76, 73};
        // [1,1,4,2,1,1,0,0]
        System.out.println(Arrays.toString(dailyTemperatures(temperatures)));
    }
}

day11 二叉树训练

labuladong 经常建议先刷二叉树的题目,因为很多经典算法,以及回溯算法、动态规划算法,其实都是树的问题,而树的问题有规律,永远逃不开树的递归遍历框架这几行代码:

/* 二叉树遍历框架 */
void traverse(TreeNode root) {
    // 前序遍历
    traverse(root.left)
    // 中序遍历
    traverse(root.right)
    // 后序遍历
}

01- 二叉树的重要性

快速排序就是个二叉树的前序遍历,归并排序就是个二叉树的后序遍历

快速排序

快速排序的逻辑是,若要对 nums[lo…hi] 进行排序,我们先找一个分界点 p,通过交换元素使得 nums[lo…p-1] 都小于等于 nums[p],且 nums[p+1…hi] 都大于 nums[p],然后递归地去 nums[lo…p-1] 和 nums[p+1…hi] 中寻找新的分界点,最后整个数组就被排序了

void sort(int[] nums, int lo, int hi) {
    /****** 前序遍历位置 ******/
    // 通过交换元素构建分界点 p
    int p = partition(nums, lo, hi);
    /************************/

    sort(nums, lo, p - 1);
    sort(nums, p + 1, hi);
}

先构造分界点,然后去左右子数组构造分界点,你看这不就是一个二叉树的前序遍历吗?

归并排序

再说说归并排序的逻辑,若要对 nums[lo…hi] 进行排序,我们先对 nums[lo…mid] 排序,再对 nums[mid+1…hi] 排序,最后把这两个有序的子数组合并,整个数组就排好序了。

归并排序的代码框架如下:

void sort(int[] nums, int lo, int hi) {
    int mid = (lo + hi) / 2;
    sort(nums, lo, mid);
    sort(nums, mid + 1, hi);

    /****** 后序遍历位置 ******/
    // 合并两个排好序的子数组
    merge(nums, lo, mid, hi);
    /************************/
}

先对左右子数组排序,然后合并(类似合并有序链表的逻辑),你看这是不是二叉树的后序遍历框架?

另外,这不就是传说中的分治算法嘛。如果你一眼就识破这些排序算法的底细,还需要背这些算法代码吗?这不是手到擒来,从框架慢慢扩展就能写出算法了。说了这么多,旨在说明,二叉树的算法思想的运用广泛,甚至可以说,只要涉及递归,都可以抽象成二叉树的问题。

02- 写递归算法的秘诀

写递归算法的关键是要明确函数的「定义」是什么,然后相信这个定义,利用这个定义推导最终结果,绝不要跳入递归的细节。

计算一棵二叉树共有几个节点:

// 定义:count(root) 返回以 root 为根的树有多少节点
int count(TreeNode root) {
    // base case
    if (root == null) return 0;
    // 自己加上子树的节点数就是整棵树的节点数
    return 1 + count(root.left) + count(root.right);
}

root 本身就是一个节点,加上左右子树的节点数就是以 root 为根的树的节点总数。
左右子树的节点数怎么算?其实就是计算根为 root.left 和 root.right 两棵树的节点数呗,按照定义,递归调用 count 函数即可算出来。
写树相关的算法,简单说就是,先搞清楚当前 root 节点「该做什么」以及「什么时候做」,然后根据函数定义递归调用子节点,递归调用会让孩子节点做相同的事情。
所谓「该做什么」就是让你想清楚写什么代码能够实现题目想要的效果,所谓「什么时候做」,就是让你思考这段代码到底应该写在前序、中序还是后序遍历的代码位置上。

03- 算法实践

几道练手题

a. 翻转二叉树

二叉树题目的一个难点就是,如何把题目的要求细化成每个节点需要做的事情

/**
 * 226. 翻转二叉树
 * https://leetcode-cn.com/problems/invert-binary-tree/
 */
public class InvertTreeCase {
    public TreeNode invertTree(TreeNode root) {
        // base case 
        if (root == null){
            return null;
        }
        TreeNode temp = root.left;
        root.left = invertTree(root.right);
        root.right = invertTree(temp);
        return root;
    }
}
b. 填充每个节点的下一个右侧节点指针

解法一 双队列层序遍历

/**
 * 116. 填充每个节点的下一个右侧节点指针
 * https://leetcode-cn.com/problems/populating-next-right-pointers-in-each-node/
 */
public Node connect(Node root) {
    if (root == null) {
        return null;
    }
    Queue<Node> nodeQueue = new ArrayDeque<>();
    nodeQueue.add(root);
    // 层序遍历
    while (!nodeQueue.isEmpty()) {
        Queue<Node> levelQueue = new ArrayDeque<>();
        // 将每层的结点放入到 level 队列
        while (!nodeQueue.isEmpty()) {
            levelQueue.add(nodeQueue.poll());
        }
        // 每层进行链接
        while (!levelQueue.isEmpty()) {
            // 删除当前头结点
            Node node = levelQueue.poll();
            if (levelQueue != null) {
                nodeQueue.add(node.left);
                nodeQueue.add(node.right);
            }

            // 删除出的结点的 next 结点的下一结点是队列的头结点
            if (!levelQueue.isEmpty()) {
                node.next = levelQueue.peek();
            } else {
                node.next = null;
            }
        }
    }
    return root;
}

解法二 双队列层序遍历

/**
 * 116. 填充每个节点的下一个右侧节点指针
 * https://leetcode-cn.com/problems/populating-next-right-pointers-in-each-node/
 */
// 主函数
Node connect(Node root) {
    if (root == null) return null;
    connectTwoNode(root.left, root.right);
    return root;
}

// 辅助函数
void connectTwoNode(Node node1, Node node2) {
    if (node1 == null || node2 == null) {
        return;
    }
    /**** 前序遍历位置 ****/
    // 将传入的两个节点连接
    node1.next = node2;

    // 连接相同父节点的两个子节点
    connectTwoNode(node1.left, node1.right);
    connectTwoNode(node2.left, node2.right);
    // 连接跨越父节点的两个子节点
    connectTwoNode(node1.right, node2.left);
}
c. 二叉树展开为链表

递归 + 前序遍历

二叉树转链表图示

/**
 * 114. 二叉树展开为链表
 * https://leetcode-cn.com/problems/flatten-binary-tree-to-linked-list/
 */
public class FlattenCase {
    // 定义:将以 root 为根的树拉平为链表
    void flatten(TreeNode root) {
        // base case
        if (root == null) return;
        flatten(root.left);
        flatten(root.right);

        TreeNode left = root.left;
        TreeNode right = root.right;

        root.left = null;
        root.right = left;

        TreeNode p = root;
        while (p.right != null) {
            p = p.right;
        }
        p.right = right;
    }
}

day12 二叉搜索树基础

二叉搜索树(BST)简介

所谓二叉搜索树(Binary Search Tree,简称 BST)大家应该都不陌生,它是一种特殊的二叉树。特殊在哪里呢?简单来说就是:左小右大。BST 的完整定义如下:

  1. BST 中任意一个节点的左子树所有节点的值都小于该节点的值,右子树所有节点的值都大于该节点的值。
  2. BST 中任意一个节点的左右子树都是 BST。

有了 BST 的这种特性,就可以在二叉树中做类似二分搜索的操作,搜索一个元素的效率很高。

01-二叉搜索树中的搜索

根据 BST 树特性,使用二分思想写递归

/*
二叉搜索树中的搜索
https://leetcode-cn.com/problems/search-in-a-binary-search-tree/submissions/
 */
public class SearchBSTCase {
    public TreeNode searchBST(TreeNode root, int val) {
        if (root == null) {
            return null;
        }
        if (root.val == val) {
            return root;
        }
        if (root.val > val) {
            return searchBST(root.left, val);
        } else {
            return searchBST(root.right, val);
        }
    }
}

02-二叉搜索树中的插入操作

根据二叉树写递归插入借点即可

/*
二叉搜索树中的插入操作
https://leetcode-cn.com/problems/insert-into-a-binary-search-tree/submissions/
 */
public class InsertIntoBSTCase {
    public TreeNode insertIntoBST(TreeNode root, int val) {
        // 如果给入的是空树 / 遍历到叶子节点
        if (root == null) {
            return new TreeNode(val);
        }
        // root.val > val 往左子树上插入
        if (root.val > val) {
            root.left = insertIntoBST(root.left, val);
        }
        // root.val < val 往右子树上插入
        else if (root.val < val) {
            root.right = insertIntoBST(root.right, val);
        }
        return root;
    }
}

03-验证二叉搜索树

递归,但需要函数参数需要一点特殊的设计

/*
验证二叉搜索树
https://leetcode-cn.com/problems/validate-binary-search-tree/
 */
public class IsValidBSTCase {
    public boolean isValidBST(TreeNode root) {
        return isValidBST(root, null, null);
    }

    public boolean isValidBST(TreeNode root, TreeNode min, TreeNode max) {
        // base case : 检测过的结点全部都正确
        if (root == null) {
            return true;
        }

        // 若 root.val 不符合 max 和 min 的限制,说明不是合法 BST
        if (min != null && root.val <= min.val) return false;
        if (max != null && root.val >= max.val) return false;
        // 限定左子树的最大值是 root.val,右子树的最小值是 root.val
        // 左子树没有最小限制,右子树没有最大限制
        return isValidBST(root.left, min, root) && isValidBST(root.right, root, max);
    }
}

04-删除二叉搜索树中的节点

画图分情况处理

删除比插入和搜索都要复杂一些,分三种情况:

情况 1:A 恰好是末端节点,两个子节点都为空,那么它可以当场去世了:

情况1

情况 2:A 只有一个非空子节点,那么它要让这个孩子接替自己的位置:

情况2

情况 3:A 有两个子节点,麻烦了,为了不破坏 BST 的性质,A 必须找到左子树中最大的那个节点或者右子树中最小的那个节点来接替自己,我的解法是用右子树中最小节点来替换:

情况3

/*
删除二叉搜索树中的节点
https://leetcode-cn.com/problems/delete-node-in-a-bst/
 */
public class DeleteNodeCase {

    public TreeNode deleteNode(TreeNode root, int key) {
        if (root == null) return null;
        if (root.val == key) {
            // 这两个 if 把情况 1 和 2 都正确处理了
            if (root.left == null) return root.right;
            if (root.right == null) return root.left;
            // 处理情况 3
            // 获得右子树最小的节点
            TreeNode minNode = getMin(root.right);
            // 删除右子树最小的节点
            root.right = deleteNode(root.right, minNode.val);
            // 用右子树最小的节点替换 root 节点
            minNode.left = root.left;
            minNode.right = root.right;
            root = minNode;
        } else if (root.val > key) {
            root.left = deleteNode(root.left, key);
        } else {
            root.right = deleteNode(root.right, key);
        }
        return root;
    }

    TreeNode getMin(TreeNode node) {
        // BST 最左边的就是最小的
        while (node.left != null) node = node.left;
        return node;
    }
}

day13 BFS 搜索算法

广度优先搜索算法(BFS) 的核心思想应该不难理解的,就是把一些问题抽象成图,从一个点开始,向四周开始扩散。一般来说,我们写 BFS 算法都是用「队列」这种数据结构,每次将一个节点周围的所有节点加入队列。

BFS 相对 DFS 的最主要的区别是:BFS 找到的路径一定是最短的,但代价就是空间复杂度可能比 DFS 大很多,至于为什么,我们后面介绍了框架就很容易看出来了。

BFS 出现的常见场景:问题的本质就是让你在一幅「图」中找到从起点 start 到终点 target 的最近距离,这个例子听起来很枯燥,但是 BFS 算法问题其实都是在干这个事儿,下面是 BFS 代码框架:

// 计算从起点 start 到终点 target 的最近距离
int BFS(Node start, Node target) {
    QueueNode› q; // 核心数据结构
    SetNode› visited; // 避免走回头路
    
    q.offer(start); // 将起点加入队列
    visited.add(start);
    int step = 0; // 记录扩散的步数

    while (q not empty) {
        int sz = q.size();
        /* 将当前队列中的所有节点向四周扩散 */
        for (int i = 0; i ‹ sz; i++) {
            Node cur = q.poll();
            /* 划重点:这里判断是否到达终点 */
            if (cur is target)
                return step;
            /* 将 cur 的相邻节点加入队列 */
            for (Node x : cur.adj())
                if (x not in visited) {
                    q.offer(x);
                    visited.add(x);
                }
        }
        /* 划重点:更新步数在这里 */
        step++;
    }
}

队列 q 就不说了,BFS 的核心数据结构;cur.adj() 泛指 cur 相邻的节点,比如说二维数组中,cur 上下左右四面的位置就是相邻节点;visited 的主要作用是防止走回头路,大部分时候都是必须的,但是像一般的二叉树结构,没有子节点到父节点的指针,不会走回头路就不需要 visited。

01-二叉树的层序遍历

对二叉树的层序遍历可以看成对图的广度优先遍历

public List<List<Integer>> levelOrder(TreeNode root) {
    List<List<Integer>> ans = new ArrayList<>();
    if (root == null){
        return ans;
    }
    
    // 广度优先搜索使用队列
    Queue<TreeNode> queue = new ArrayDeque<>();
    queue.add(root);

    while (!queue.isEmpty()) {
        Queue<TreeNode> levelQueue = new ArrayDeque<>();

        int size = queue.size();
        // 添加元素到新队列里
        for (int i = 0; i < size; i++) {
            TreeNode node = queue.poll();
            levelQueue.add(node);
            // 添加左右子结点进队列
            if (node.left != null) {
                queue.add(node.left);
            }
            if (node.right != null) {
                queue.add(node.right);
            }
        }

        List<Integer> list = new ArrayList<>();
        while (!levelQueue.isEmpty()) {
            list.add(levelQueue.poll().val);
        }
        ans.add(list);
    }
    return ans;
}

02-二叉树的最小深度

对二叉树的层序遍历可以看成对图的广度优先遍历

    public int minDepth(TreeNode root) {
        if (root == null) {
            return 0;
        }
        // 广度优先搜索使用队列
        Queue<TreeNode> queue = new ArrayDeque<>();
        int level = 0;
        queue.add(root);

        while (!queue.isEmpty()) {

            int size = queue.size();
            level++;
            // 添加元素到新队列里
            for (int i = 0; i < size; i++) {
                TreeNode node = queue.poll();
                if (node.left == null && node.right == null) {
                    return level;
                }
                // 添加左右子结点进队列
                if (node.right != null) {
                    queue.add(node.right);
                }
                if (node.left != null) {
                    // add 方法不能添加 null
                    queue.add(node.left);
                }
            }
        }
        return level;
    }

1、为什么 BFS 可以找到最短距离,DFS 不行吗?

首先,你看 BFS 的逻辑,depth 每增加一次,队列中的所有节点都向前迈一步,这保证了第一次到达终点的时候,走的步数是最少的。
DFS 不能找最短路径吗?其实也是可以的,但是时间复杂度相对高很多。你想啊,DFS 实际上是靠递归的堆栈记录走过的路径,你要找到最短路径,肯定得把二叉树中所有树杈都探索完才能对比出最短的路径有多长对不对?而 BFS 借助队列做到一次一步「齐头并进」,是可以在不遍历完整棵树的条件下找到最短距离的。形象点说,DFS 是线,BFS 是面;DFS 是单打独斗,BFS 是集体行动。这个应该比较容易理解吧。

2、既然 BFS 那么好,为啥 DFS 还要存在?
BFS 可以找到最短距离,但是空间复杂度高,而 DFS 的空间复杂度较低。

还是拿刚才我们处理二叉树问题的例子,假设给你的这个二叉树是满二叉树,节点数为 N,对于 DFS 算法来说,空间复杂度无非就是递归堆栈,最坏情况下顶多就是树的高度,也就是 O(logN)。
但是你想想 BFS 算法,队列中每次都会储存着二叉树一层的节点,这样的话最坏情况下空间复杂度应该是树的最底层节点的数量,也就是 N/2,用 Big O 表示的话也就是 O(N)。
由此观之,BFS 还是有代价的,一般来说在找最短路径的时候使用 BFS,其他时候还是 DFS 使用得多一些(主要是递归代码好写)。

03-解开密码锁的最少次数

使用对字符串的深度优先算法,将数字锁的变换想成对多叉树的深度优先遍历

import org.junit.Test;
import java.util.*;

/**
 * 打开转盘锁
 * https://leetcode-cn.com/problems/open-the-lock/submissions/
 */
public class OpenLockCaseV2 {

    private Set<String> checkedSet;

    public int openLock(String[] deadends, String target) {
        checkedSet = new HashSet<>(Arrays.asList(deadends));
        String source = "0000";

        if (checkedSet.containsAll(getSubList(target)) || checkedSet.contains(source)) {
            return -1;
        }

        // 暴力深搜要全部搜完,所以使用广搜
        Queue<String> queue = new ArrayDeque<>();
        queue.add(source);
        checkedSet.add(source);
        int step = 0;

        while (!queue.isEmpty()) {
            Queue<String> levelQueue = new ArrayDeque<>();

            int size = queue.size();
            for (int i = 0; i < size; i++) {
                String s = queue.poll();
                // 添加当前树的这一层
                levelQueue.add(s);
                // 对 s 改变 1 位,相当于得到多叉树的子节点
                assert s != null;
                List<String> list = getSubList(s);
                System.out.println("list => " + list);
                // 将多叉树的子节点添加到队列中 queue 中
                queue.addAll(list);
            }

            // 逐层检测
            while (!levelQueue.isEmpty()) {
                String s = levelQueue.poll();
                System.out.println("s => " + s);

                if (s.equals(target)) {
                    return step;
                }
                // s 添加到已访问的集合里
                checkedSet.add(s);
            }
            // 步骤 +1
            step++;
//            System.out.println("step => " + step + "\n");
        }
        return -1;
    }

    // 生成并返回 str 节点的子节点
    public List<String> getSubList(String str) {
        ArrayList<String> res = new ArrayList<>();
        for (int i = 0; i < str.length(); i++) {
            String s1 = operate(str, i, '+');
            if (!checkedSet.contains(s1)) {
                res.add(s1);
            }
            String s2 = operate(str, i, '-');
            if (!checkedSet.contains(s2)) {
                res.add(s2);
            }
        }
        return res;
    }

    // 完成对字符串某一位的修改
    public String operate(String str, int i, char op) {
        // 需要变更的字符
        int c = str.charAt(i) - '0';
        // 截取变更字符前后的字符串
        String left = i == 0 ? "" : str.substring(0, i);
        String right = i == str.length() - 1 ? "" : str.substring(i + 1);

        // 得到变更位
        String c1;
        switch (op) {
            case '+': // 完成 +1
                c1 = String.valueOf((c + 1) % 10);
                break;
            case '-': // 完成 -1 (就像二进制的减法溢出处理)
                c1 = String.valueOf((c + 9) % 10);
                break;
            default:
                c1 = "";
        }
        return left + c1 + right;
    }
}

04-双向 BFS 优化

day14 并查集(Union-Find)算法【选学】

01-

在这里插入代码片

day15 LRU 算法实现

01-LRU 缓存

题目要求

请你设计并实现一个满足 LRU (最近最少使用) 缓存 约束的数据结构。
实现 LRUCache 类:

  • LRUCache(int capacity) 以 正整数 作为容量 capacity 初始化 LRU 缓存
  • int get(int key) 如果关键字 key 存在于缓存中,则返回关键字的值,否则返回 -1 。
  • void put(int key, int value) 如果关键字 key 已经存在,则变更其数据值 value ;如果不存在,则向缓存中插入该组 key-value 。如果插入操作导致关键字数量超过 capacity ,则应该 逐出 最久未使用的关键字。
  • 函数 get 和 put 必须以 O(1) 的平均时间复杂度运行。

数据结构 : 哈希链表

哈希链表

首先,我们把双链表的节点类写出来,为了简化,key 和 val 都认为是 int 类型:

class Node {
    public int key, val;
    public Node next, prev;
    public Node(int k, int v) {
        this.key = k;
        this.val = v;
    }
}

然后依靠我们的 Node 类型构建一个双链表,实现几个 LRU 算法必须的 API:

class DoubleList {  
    // 头尾虚节点
    private Node head, tail;  
    // 链表元素数
    private int size;
    
    public DoubleList() {
        // 初始化双向链表的数据
        head = new Node(0, 0);
        tail = new Node(0, 0);
        head.next = tail;
        tail.prev = head;
        size = 0;
    }

    // 在链表尾部添加节点 x,时间 O(1)
    public void addLast(Node x) {
        x.prev = tail.prev;
        x.next = tail;
        tail.prev.next = x;
        tail.prev = x;
        size++;
    }

    // 删除链表中的 x 节点(x 一定存在)
    // 由于是双链表且给的是目标 Node 节点,时间 O(1)
    public void remove(Node x) {
        x.prev.next = x.next;
        x.next.prev = x.prev;
        size--;
    }
    
    // 删除链表中第一个节点,并返回该节点,时间 O(1)
    public Node removeFirst() {
        if (head.next == tail)
            return null;
        Node first = head.next;
        remove(first);
        return first;
    }

    // 返回链表长度,时间 O(1)
    public int size() { return size; }

}

到这里就能回答刚才「为什么必须要用双向链表」的问题了,因为我们需要删除操作。删除一个节点不光要得到该节点本身的指针,也需要操作其前驱节点的指针,而双向链表才能支持直接查找前驱,保证操作的时间复杂度 O(1)。
注意我们实现的双链表 API 只能从尾部插入,也就是说靠尾部的数据是最近使用的,靠头部的数据是最久为使用的。

有了双向链表的实现,我们只需要在 LRU 算法中把它和哈希表结合起来即可,先搭出代码框架:

class LRUCache {
    // key -› Node(key, val)
    private HashMapInteger, Node› map;
    // Node(k1, v1) ‹-› Node(k2, v2)...
    private DoubleList cache;
    // 最大容量
    private int cap;
    
    public LRUCache(int capacity) {
        this.cap = capacity;
        map = new HashMap‹›();
        cache = new DoubleList();
    }
}

先不慌去实现 LRU 算法的 get 和 put 方法。由于我们要同时维护一个双链表 cache 和一个哈希表 map,很容易漏掉一些操作,比如说删除某个 key 时,在 cache 中删除了对应的 Node,但是却忘记在 map 中删除 key。
解决这种问题的有效方法是:在这两种数据结构之上提供一层抽象 API。
说的有点玄幻,实际上很简单,就是尽量让 LRU 的主方法 get 和 put 避免直接操作 map 和 cache 的细节。我们可以先实现下面几个函数:

/* 将某个 key 提升为最近使用的 */
private void makeRecently(int key) {
    Node x = map.get(key);
    // 先从链表中删除这个节点
    cache.remove(x);
    // 重新插到队尾
    cache.addLast(x);
}

/* 添加最近使用的元素 */
private void addRecently(int key, int val) {
    Node x = new Node(key, val);
    // 链表尾部就是最近使用的元素
    cache.addLast(x);
    // 别忘了在 map 中添加 key 的映射
    map.put(key, x);
}

/* 删除某一个 key */
private void deleteKey(int key) {
    Node x = map.get(key);
    // 从链表中删除
    cache.remove(x);
    // 从 map 中删除
    map.remove(key);
}

/* 删除最久未使用的元素 */
private void removeLeastRecently() {
    // 链表头部的第一个元素就是最久未使用的
    Node deletedNode = cache.removeFirst();
    // 同时别忘了从 map 中删除它的 key
    int deletedKey = deletedNode.key;
    map.remove(deletedKey);
}

这里就能回答之前的问答题「为什么要在链表中同时存储 key 和 val,而不是只存储 val」,注意 removeLeastRecently 函数中,我们需要用 deletedNode 得到 deletedKey。
也就是说,当缓存容量已满,我们不仅仅要删除最后一个 Node 节点,还要把 map 中映射到该节点的 key 同时删除,而这个 key 只能由 Node 得到。如果 Node 结构中只存储 val,那么我们就无法得知 key 是什么,就无法删除 map 中的键,造成错误。
上述方法就是简单的操作封装,调用这些函数可以避免直接操作 cache 链表和 map 哈希表,下面我先来实现 LRU 算法的 get 方法:

public int get(int key) {
    if (!map.containsKey(key)) {
        return -1;
    }
    // 将该数据提升为最近使用的
    makeRecently(key);
    return map.get(key).val;
}

put 方法稍微复杂一些,我们先来画个图搞清楚它的逻辑:

put的逻辑

这样我们可以轻松写出 put 方法的代码:

public void put(int key, int val) {
    if (map.containsKey(key)) {
        // 删除旧的数据
        deleteKey(key);
        // 新插入的数据为最近使用的数据
        addRecently(key, val);
        return;
    }
    
    if (cap == cache.size()) {
        // 删除最久未使用的元素
        removeLeastRecently();
    }
    // 添加为最近使用的元素
    addRecently(key, val);
}
V1 自定义数据结构

上面逻辑的完整代码

import java.util.HashMap;

class LRUCacheAnsV1 {
    
    class Node {
        public int key, val;
        public Node next, prev;

        public Node(int k, int v) {
            this.key = k;
            this.val = v;
        }
    }

    class DoubleList {
        // 头尾虚节点
        private Node head, tail;
        // 链表元素数
        private int size;

        public DoubleList() {
            // 初始化双向链表的数据
            head = new Node(0, 0);
            tail = new Node(0, 0);
            head.next = tail;
            tail.prev = head;
            size = 0;
        }

        // 在链表尾部添加节点 x,时间 O(1)
        public void addLast(Node x) {
            x.prev = tail.prev;
            x.next = tail;
            tail.prev.next = x;
            tail.prev = x;
            size++;
        }

        // 删除链表中的 x 节点(x 一定存在)
        // 由于是双链表且给的是目标 Node 节点,时间 O(1)
        public void remove(Node x) {
            x.prev.next = x.next;
            x.next.prev = x.prev;
            size--;
        }

        // 删除链表中第一个节点,并返回该节点,时间 O(1)
        public Node removeFirst() {
            if (head.next == tail)
                return null;
            Node first = head.next;
            remove(first);
            return first;
        }

        // 返回链表长度,时间 O(1)
        public int size() {
            return size;
        }

    }

    // key -> Node(key, val)
    private HashMap<Integer, Node> map;
    // Node(k1, v1) <-> Node(k2, v2)...
    private DoubleList cache;
    // 最大容量
    private int cap;

    public LRUCacheAnsV1(int capacity) {
        this.cap = capacity;
        map = new HashMap<>();
        cache = new DoubleList();
    }

    /* 将某个 key 提升为最近使用的 */
    private void makeRecently(int key) {
        Node x = map.get(key);
        // 先从链表中删除这个节点
        cache.remove(x);
        // 重新插到队尾
        cache.addLast(x);
    }

    /* 添加最近使用的元素 */
    private void addRecently(int key, int val) {
        Node x = new Node(key, val);
        // 链表尾部就是最近使用的元素
        cache.addLast(x);
        // 别忘了在 map 中添加 key 的映射
        map.put(key, x);
    }

    /* 删除某一个 key */
    private void deleteKey(int key) {
        Node x = map.get(key);
        // 从链表中删除
        cache.remove(x);
        // 从 map 中删除
        map.remove(key);
    }

    /* 删除最久未使用的元素 */
    private void removeLeastRecently() {
        // 链表头部的第一个元素就是最久未使用的
        Node deletedNode = cache.removeFirst();
        // 同时别忘了从 map 中删除它的 key
        int deletedKey = deletedNode.key;
        map.remove(deletedKey);
    }

    public int get(int key) {
        if (!map.containsKey(key)) {
            return -1;
        }
        // 将该数据提升为最近使用的
        makeRecently(key);
        return map.get(key).val;
    }

    public void put(int key, int val) {
        if (map.containsKey(key)) {
            // 删除旧的数据
            deleteKey(key);
            // 新插入的数据为最近使用的数据
            addRecently(key, val);
            return;
        }

        if (cap == cache.size()) {
            // 删除最久未使用的元素
            removeLeastRecently();
        }
        // 添加为最近使用的元素
        addRecently(key, val);
    }
}
V2 使用LinkedHashMap结构
import java.util.LinkedHashMap;

class LRUCacheAnsV2 {
    int cap;
    LinkedHashMap<Integer, Integer> cache = new LinkedHashMap<>();
    public LRUCacheAnsV2(int capacity) {
        this.cap = capacity;
    }

    public int get(int key) {
        if (!cache.containsKey(key)) {
            return -1;
        }
        // 将 key 变为最近使用
        makeRecently(key);
        return cache.get(key);
    }

    public void put(int key, int val) {
        if (cache.containsKey(key)) {
            // 修改 key 的值
            cache.put(key, val);
            // 将 key 变为最近使用
            makeRecently(key);
            return;
        }

        if (cache.size() >= this.cap) {
            // 链表头部就是最久未使用的 key
            int oldestKey = cache.keySet().iterator().next();
            cache.remove(oldestKey);
        }
        // 将新的 key 添加链表尾部
        cache.put(key, val);
    }

    private void makeRecently(int key) {
        int val = cache.get(key);
        // 删除 key,重新插入到队尾
        cache.remove(key);
        cache.put(key, val);
    }
}

day16 LFU算法

讲解 LFU 算法

01-LFU 缓存

困难;LFU( 最不经常使用)的一种缓存淘汰策略

/**
 * LFU 缓存
 * https://leetcode-cn.com/problems/lfu-cache/submissions/
 */
public class LFUCache {
}

day17 回溯算法原理

讲解回溯算法的基础原理

01-回溯基础原理讲解


02-全排列

给定不重复的数据,生成全排列

/**
 * 全排列(没有重复元素)
 * https://leetcode-cn.com/problems/permutations/
 */

import org.junit.Test;

import java.util.LinkedList;
import java.util.List;

public class PermuteCase {


    public List<List<Integer>> permute(int[] nums) {
        // visited 记录已经访问过的元素
        boolean[] visited = new boolean[nums.length];
        // 使用 LinkedList 移除元素更快
        List<Integer> temp = new LinkedList<>();
        List<List<Integer>> ans = new LinkedList<>();
        permute(nums, visited, temp, ans);
        return ans;
    }

    public void permute(int[] nums, boolean[] visited, List<Integer> temp, List<List<Integer>> ans) {
        // base case :已经生成新的排列
        if (temp.size() == nums.length) {
            ans.add(new LinkedList<>(temp));
            return;
        }

        for (int i = 0; i < nums.length; i++) {
            // !visited[i] => 确保添加的元素是不重复的
            if (visited[i]) {
                continue;
            }
            // 标记
            visited[i] = true;
            // 选择
            temp.add(nums[i]);
            permute(nums, visited, temp, ans);
            // 撤销选择 直接用 int 型移除的是索引对应的元素
            temp.remove(Integer.valueOf(nums[i]));
            // 撤销标记
            visited[i] = false;
        }
    }

    @Test
    public void test() {
        int[] nums = {1, 2, 3};
        // 输出:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]
        System.out.println(permute(nums));
    }
}

03-N 皇后


day18 回溯算法运用

讲解回溯算法的基础原理

01-LFU 缓存

困难;LFU( 最不经常使用)的一种缓存淘汰策略

/**
 * LFU 缓存
 * https://leetcode-cn.com/problems/lfu-cache/submissions/
 */
public class LFUCache {
}

day19 动态规划原理

讲解回溯算法的基础原理

01-LFU 缓存

困难;LFU( 最不经常使用)的一种缓存淘汰策略

/**
 * LFU 缓存
 * https://leetcode-cn.com/problems/lfu-cache/submissions/
 */
public class LFUCache {
}

day20 动态规划设计

讲解回溯算法的基础原理

01-LFU 缓存

困难;LFU( 最不经常使用)的一种缓存淘汰策略

/**
 * LFU 缓存
 * https://leetcode-cn.com/problems/lfu-cache/submissions/
 */
public class LFUCache {
}

day21 总结

讲解回溯算法的基础原理

01-LFU 缓存

困难;LFU( 最不经常使用)的一种缓存淘汰策略

/**
 * LFU 缓存
 * https://leetcode-cn.com/problems/lfu-cache/submissions/
 */
public class LFUCache {
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值