Bean安不安全这个取决于其状态以及作用域

我们这里以常用的两种作用域single以及prototype做介绍。几乎所有的应用场景都是默认的single,所以我们

重点关注single就好

像prototype这种就是每次getBean时拿的都是一个新的Bean,那么像这种就是就不存在竞争问题,即是安全

而single作用域下,ioc容器拥有唯一的Bean实例。可能存在资源竞争问题,取决于Bean是否有状态。如果这

个Bean是有状态的,那么就存在线程安全的问题,这里的有无状态指的是是否包含可变的成员变量

不过,大部分 Bean 实际都是无状态(没有定义可变的成员变量)的(比如 Dao、Service),这种情况下, Bean 是线程安全的。对于有状态单例 Bean 的线程安全问题,常见的有两种解决办法:

1.在 Bean 中尽量避免定义可变的成员变量。

2.在类中定义一个 ThreadLocal 成员变量,将需要的可变成员变量保存在 ThreadLocal 中(推荐的一种方式)。

即使是无状态的bean也不一定线程安全,可能还需要考虑你的方法有没有涉及到其它类的静态成员变量的资源竞争

eg:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Service
public class UserCacheService {
private static final Map<Long, String> userCache = new HashMap<>();

public void put(Long id, String name) {
userCache.put(id, name); // HashMap 非线程安全
}

public String get(Long id) {
return userCache.get(id);
}
}

map线程不安全的,用cmap就好了

  • singleton : IoC 容器中只有唯一的 bean 实例。Spring 中的 bean 默认都是单例的,是对单例设计模式的应用。
  • **prototype **: 每次获取都会创建一个新的 bean 实例。也就是说,连续 getBean() 两次,得到的是不同的 Bean 实例。
  • **request **(仅 Web 应用可用): 每一次 HTTP 请求都会产生一个新的 bean(请求 bean),该 bean 仅在当前 HTTP request 内有效。
  • **session **(仅 Web 应用可用) : 每一次来自新 session 的 HTTP 请求都会产生一个新的 bean(会话 bean),该 bean 仅在当前 HTTP session 内有效。
  • application/global-session (仅 Web 应用可用):每个 Web 应用在启动时创建一个 Bean(应用 Bean),该 bean 仅在当前应用启动时间内有效。
  • **websocket **(仅 Web 应用可用):每一次 WebSocket 会话产生一个新的 bean。

如何配置:

xml方式:

注解方式:

@Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE)

BeanFactory是Spring ioc容器的一个接口,用来获取以及管理Bean的依赖注入和生命周期

FactoryBean是一个接口,用来定义一个工厂Bean,它可以产生某种类型的对象。如果一个Bean实现了FactoryBean,你获取的时候不是直接返回Bean实例,而是返回FactoryBean中getObejct返回的对象,通常用于创建很复杂的对象

eg:复杂的对象:jedis客户端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import org.springframework.beans.factory.FactoryBean;
import org.springframework.stereotype.Component;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;

@Component("redisClient")
public class RedisClientFactoryBean implements FactoryBean<JedisPool> {

// 模拟配置,可以改成 @Value 注入 application.properties 里的值
private String host = "localhost";
private int port = 6379;

@Override
public JedisPool getObject() throws Exception {
System.out.println("初始化 Redis 连接池...");
JedisPoolConfig config = new JedisPoolConfig();
config.setMaxTotal(10);
return new JedisPool(config, host, port);
}

@Override
public Class<?> getObjectType() {
return JedisPool.class;
}

@Override
public boolean isSingleton() {
return true;
}
}

使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;

@Service
public class RedisService {

@Autowired
private JedisPool jedisPool; // 注意:注入的就是 getObject() 返回的 JedisPool

public String get(String key) {
try (Jedis jedis = jedisPool.getResource()) {
return jedis.get(key);
}
}

public void set(String key, String value) {
try (Jedis jedis = jedisPool.getResource()) {
jedis.set(key, value);
}
}
}

@Autowired属于Spring内部注解,默认根据你类型注入,也就是默认优先根据接口的类型去匹配并注入bean

当一个接口存在多个实现类的话,byType这种就无法正确地注入对象了,因为Spring会同时找到多个对象,

然而他并不知道注入哪个(会抛错)

这时候可以通过@qualifer(“xx”)去标明注入哪个类(实现类的小驼峰命名),或者通过根据变量名自动根据

名字匹配。推荐用前者,可读性更强

@Resource是JDK提供的,默认注入类型为byName。如果无法通过name注入名称,那就会变成byType

它两个属性,一个type,一个name。声明哪个用哪个,如果都声明,那就是type+name

@Autowired可在构造方法,方法,参数,字段上使用

@Resource只能在方法和字段上使用

@Autowired是Spring官方的注解,@Resource是JDK的注解,更像是一个标准或者约定,所有的IOC容器都

支持这个注解

前缀和

滑动窗口

分为不定长(最小,最大),定长窗口

时间复杂度都是ON

排列,组合,子集背诵

排列:

组合:

子集:

时间复杂度

“区分 O(n) 和 O(n²) 主要看指针是否会反复从头开始。像冒泡排序那样,每次外层循环都让内层从 0 跑到 n,就是 O(n²)。但滑动窗口里,l 和 r 都是单调递增的,每个字符只会进窗口和出窗口一次,总次数是线性的,所以是 O(n)。”

🚩 常见时间复杂度 & 如何看出来

1. O(1) 常数时间

  • 特征:执行步骤跟输入规模无关。
  • 例子:
1
2
int x = arr[5];   // 随机访问数组
map.put("a", 1); // HashMap 插入
  • 技巧:只要操作次数固定,不随 n 增长,就是 O(1)。

2. O(log n) 对数时间

  • 特征:每次操作把问题规模缩小一半。
  • 例子:
1
2
3
4
5
// 二分查找
while (low <= high) {
mid = (low + high) / 2;
...
}
  • 技巧:遇到 “二分 / 分治” → log n。

3. O(n) 线性时间

  • 特征:每个元素只处理有限次(常数次)。
  • 例子:
1
2
3
for (int i = 0; i < n; i++) {
...
}

或滑动窗口:左右指针各走一遍数组。

  • 技巧数一数元素最多被访问几次,如果 ≤ 常数次 → O(n)。

4. O(n log n)

  • 特征:线性扫描 + 对数拆分。
  • 典型:排序算法(快排、归并、堆排)。
    • 快排:O(n) 分区 + O(log n) 递归深度 → O(n log n)。
    • 堆排序:每次调整堆 O(log n),n 次 → O(n log n)。
  • 技巧:看到 “排序”“分治+合并”,大概率是 O(n log n)。

5. O(n²)

  • 特征:双重循环,内层循环和外层循环完全独立
  • 例子:
1
2
3
4
5
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
...
}
}
  • 技巧:如果每次外层循环都让内层跑一遍 n → O(n²)。

6. O(2^n) 指数级

  • 特征:每一步有两个分支,递归深度为 n。
  • 例子:子集枚举、回溯搜索。
1
2
3
4
5
void dfs(int i) {
if (i == n) return;
dfs(i+1); // 选
dfs(i+1); // 不选
}

→ 共 2^n 种可能。

  • 技巧:遇到 “子集 / 全排列 / 括号生成” → 可能是指数复杂度。

7. O(n!) 阶乘级

  • 特征:枚举全排列。
  • 例子:旅行商问题,生成 n 个数的全排列。
  • 技巧:看到 “全排列” → n!。

最大子数组和[🔥350]

题目:就是给你一个大数组,然返回和最大的小数组

思路:

  1. 前缀和
  2. 然后线性更新res= curPreSum - minPreSum,然后更新minPreSum

注意:

  1. res的初始化
  2. 以及前缀和数组的初始值是0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Solution {
public int maxSubArray(int[] nums) {
if(nums.length == 1) return nums[0];
int []preSum = getPreSum(nums);
//找最大的和,那么就找最小前缀和 不断更新
//System.out.println(Arrays.toString(preSum));
int minPreSum=0;
int res=Integer.MIN_VALUE;;
for(int i=1;i<=nums.length;i++){
res = Math.max(res,preSum[i]-minPreSum);
minPreSum = Math.min(preSum[i],minPreSum);
}
return res;
}
public int[] getPreSum(int []nums){
int []res =new int[nums.length+1];
res[0]=0;
for(int i=1;i<=nums.length;i++){
res[i]=nums[i-1]+res[i-1];
}
return res;
}
}

搜索旋转排序数组[🔥285]

题目:从一个部分有序的数组中以logN的时间复杂度找数

思路:

  1. 有序中找东西就应该想到用二分查找,部分有序也是如此
  2. 部分有序就对此进行拆分嘛,分类讨论,左有序,还是右序
  3. 然后在各自的有序区域内进行搜索

https://chatgpt.com/s/t_68c997c4d3948191a4497c009540388e

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class Solution {
public int search(int[] nums, int target) {
int l = 0;
int r = nums.length - 1;
while (l <= r) {
int mid = l + (r - l) / 2;
if (nums[mid] == target) {
return mid;
}
//如果 nums[mid] >= nums[l],说明左半边有序,否则右半边有序
//例如 [4,5,6,7,0,1,2],如果 mid 在前半段,那 [l...mid] 一定是升序。
//第一个if就起到寻找 有序区间的作用
if (nums[mid] >= nums[l]) {
//判断是否在这个左的单调区间内
// l.......mid
if (target >= nums[l] && target <= nums[mid]) {
r = mid - 1;
} else
l = mid + 1;
}

else {
//判断是否在这个右的单调区间内
//// mid.......r
if (target <= nums[r] && target >= nums[mid]) {
l = mid + 1;
} else
r = mid - 1;
}
}
return -1;
}
}

扩展:

这道题要注意:如果面试官问你再旋转一次怎么做,做法还是一样的,无论旋转几次,最多只有2段递增序列

难度加强版:https://leetcode.cn/problems/search-rotate-array-lcci/description/

字节一面对这一题做了改造,如果target存在返回target,如果target不存在,返回 > target的最小值

维护一个>target的最小值

if (nums[mid] > target) {

// nums[mid] 是一个候选答案

if (ans == null || nums[mid] < ans) {

ans = nums[mid];

}

}

合并两个有序数组[🔥267]

题目:两个有序数组,然后合并成一个

思路:

  1. 三指针+从后向前遍历,p1,p2,p3

  2. 优先级队列

  3. 排序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class Solution {
public void merge(int[] nums1, int m, int[] nums2, int n) {
int p1=m-1;
int p2=n-1;
int p3=m+n-1;
// 1 2 3 6 , 3 4 5
while(p1>=0&&p2>=0){
if(nums1[p1]>=nums2[p2]){
nums1[p3]=nums1[p1];
p1--;
}
else {
nums1[p3]=nums2[p2];
p2--;
}
p3--;
}
while(p2>=0){
nums1[p3]=nums2[p2];
p3--;
p2--;
}
while(p1>=0){
nums1[p3]=nums1[p1];
p3--;
p1--;
}

}
}

买卖股票的最佳时机[🔥257]

题目:实现最低买入,最高卖出

思路:贪心,肯定是得选最低价的天买入,维护一个最小值即可。

1
2
3
4
5
6
7
8
9
10
11
12
class Solution {
public int maxProfit(int[] prices) {
int res=0;
//肯定是得选最低价的天买入
int minPrice =Integer.MAX_VALUE;
for(int i=0;i<prices.length;i++){
res=Math.max(res,prices[i]-minPrice);
minPrice=Math.min(minPrice,prices[i]);
}
return res;
}
}

https://leetcode-cn.com/circle/article/qiAgHn/,这篇文章讲的很通俗易懂,把几种情形都包括了

螺旋矩阵[238]

题目:顺时针螺旋顺序遍历二维数组

思路:

  1. 维护四个变量,然后模拟四个方向,通过 i 指针for循环遍历
  2. 注意边界问题
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import java.util.*;

class Solution {
public List<Integer> spiralOrder(int[][] matrix) {
int m = matrix.length; // 行数
int n = matrix[0].length; // 列数

// 定义四个边界
int up = 0, down = m - 1;
int left = 0, right = n - 1;

List<Integer> res = new ArrayList<>();

// 循环条件:还没有遍历完整个矩阵
while (left <= right && up <= down) {

// 1. 从左到右,遍历上边界
for (int i = left; i <= right && res.size() < m * n; i++) {
res.add(matrix[up][i]);
}

// 2. 从上到下,遍历右边界(注意不包含 corner)
for (int i = up + 1; i <= down - 1 && res.size() < m * n; i++) {
res.add(matrix[i][right]);
}

// 3. 从右到左,遍历下边界
for (int i = right; i >= left && res.size() < m * n; i--) {
res.add(matrix[down][i]);
}

// 4. 从下到上,遍历左边界(注意不包含 corner)
for (int i = down - 1; i >= up + 1 && res.size() < m * n; i--) {
res.add(matrix[i][left]);
}

// 缩小边界,进入下一圈
left++;
right--;
up++;
down--;
}

return res;
}
}

合并区间[206]

题目:把多个区间,如果重叠的,就合并成多个

思路:

  1. 左端点进行排序
  2. 初始化List<int[]> tempList
  3. 然后再比较Cur区间左断点是否小于前一个区间的右断点,是的话就更新tempList里的前一个区间的右断点,不是就加入tempList
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Solution {
public int[][] merge(int[][] intervals) {
//排序
//把情况进行一个合并
//[2,5]
// [4,6]

Arrays.sort(intervals,(iv1,iv2)->(iv1[0]-iv2[0]));
List<int []> temp=new ArrayList<>();
for(int []interval:intervals){
int size=temp.size();
//存在交集
//执行合并逻辑
//[ ]
// [ ]
if(size>0&&interval[0]<=temp.get(size-1)[1]){
temp.get(size-1)[1]=Math.max(interval[1],temp.get(size-1)[1]);
}
//无法合并就加入
else temp.add(interval);


}
return temp.toArray(new int[temp.size()][2]);
}
}

接雨水[183]

给定 n 个非负整数表示每个宽度为 1 的柱子的高度图,计算按此排列的柱子,下雨之后能接多少雨水。

题目:找凹进去最多的区域

思路:

  1. 什么决定了雨水最多能装多少,即最高左边界,最高右边界
  2. OK,明白以上两点,我们就可以通过双指针来遍历,找出最高左边界,以及右边界
  3. 但实际雨水能装多少,还得决定于这段区间内的空格有多少,如何更新求出空格个数呢?

最高边界-当前位置高度即 可以装水的空格

最高边界是哪个边界?短板效应,即能装多少水取决于最短的最高边界

  1. 维护一个res,res+=当前位置装水空格
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution {
public int trap(int[] height) {
int leftMax=0;
int rightMax=0;
int r=height.length-1;
int l=0;
int res=0;
while(l<r){
leftMax=Math.max(leftMax,height[l]);
rightMax=Math.max(rightMax,height[r]);
//哪边小先计算哪边,因为木桶效应,装水取决于短的那边
int water= leftMax<rightMax? leftMax-height[l++]:rightMax-height[r--];
res+=water;
}
return res;
}
}

寻找两个正序数组的中位数[156]

看不懂,先摆着

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class Solution {
public double findMedianSortedArrays(int[] A, int[] B) {
int m = A.length, n = B.length;
// 保证在短数组上二分
if (m > n) return findMedianSortedArrays(B, A);

int left = 0, right = m;
int halfLen = (m + n + 1) / 2;

while (left <= right) {
int i = (left + right) / 2; // A 的划分位置
int j = halfLen - i; // B 的划分位置

int A_left = (i == 0) ? Integer.MIN_VALUE : A[i-1];
int A_right = (i == m) ? Integer.MAX_VALUE : A[i];
int B_left = (j == 0) ? Integer.MIN_VALUE : B[j-1];
int B_right = (j == n) ? Integer.MAX_VALUE : B[j];

if (A_left <= B_right && B_left <= A_right) {
// 找到正确划分
int maxLeft = Math.max(A_left, B_left);
if ((m + n) % 2 == 1) return maxLeft;
int minRight = Math.min(A_right, B_right);
return (maxLeft + minRight) / 2.0;
}
else if (A_left > B_right) right = i - 1; // i 太大
else left = i + 1; // i 太小
}
return 0.0;
}
}

下一个排列[132]

题目:给你一个数组,该数组构成一个数字,找到比该数字大的最小值,且该最小值里数必须是数组里面的

arr =

  • 例如,arr = [1,2,3] 的下一个排列是 [1,3,2]

必须原地修改,只允许使用额外常数空间。

开背吧

思路:

  1. 纯纯找规律
  2. 从右到左 找到递增的边界。eg:[1,2,4,3,1] 那边边界就是2,我们mark为x
  3. 然后还是从右到左找到 x 右边最小大于x的数 y。eg:[1,2,4,3,1] y为3
  4. 然后交换x和y的位置。eg:[1,2,4,3,1] —> [1,3,4,2,1]
  5. reverse(原x位置,n-1),如果第二步找不到x即x=-1,然后就直接跳这一步。eg:[1,2,4,3,1] —> [1,3,1,2,4]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
class Solution {
public void nextPermutation(int[] nums) {
int n = nums.length;

// 第一步:从右向左找到第一个小于右侧相邻数字的数 nums[i]
int i = n - 2;
while (i >= 0 && nums[i] >= nums[i + 1]) {
i--;
}

// 如果找到了,进入第二步;否则跳过第二步,反转整个数组
if (i >= 0) {
// 第二步:从右向左找到 nums[i] 右边最小的大于 nums[i] 的数 nums[j]
int j = n - 1;
while (nums[j] <= nums[i]) {
j--;
}
// 交换 nums[i] 和 nums[j]
swap(nums, i, j);
}

// 第三步:反转 [i+1, n-1](如果上面跳过第二步,此时 i = -1)
reverse(nums, i + 1, n - 1);
}

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

private void reverse(int[] nums, int left, int right) {
while (left < right) {
swap(nums, left++, right--);
}
}
}

作者:灵茶山艾府
链接:https://leetcode.cn/problems/next-permutation/solutions/3621022/jiao-ni-cong-ling-kai-shi-si-kao-zhe-ti-9qfrq/
来源:力扣(LeetCode)
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

上一个排列解法只需要改变两点:1.大于号换成小于号2.排降序

缺失的第一个正数[108]

题目:目标数据[1,2,3,4,5,6……] ,让你找出缺失的第一个正数

思路:

  1. 先把有的整理一遍,寻找i这个位置上对的人 nums[i] != nums[nums[i] - 1]
  2. 然后for循环找出来
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class Solution {
public int firstMissingPositive(int[] nums) {
//先把元素交换到正确的位置上
for (int i = 0; i < nums.length; i++) {
//while循环在干什么? 在寻找i这个位置上对的人!!!!
//即 i 上应该坐着 i+1 即 nums[i] == nums[nums[i]-1】 => i = nums[i] - 1
while (nums[i] > 0
&& nums[i] <= nums.length
&& nums[i] != nums[nums[i] - 1]) {

int temp = nums[nums[i] - 1];
nums[nums[i] - 1] = nums[i];
nums[i] = temp;

}
}
//System.out.println(Arrays.toString(nums));
for (int i = 0; i < nums.length; i++) {
if (nums[i] != i + 1) {
return i + 1;
}
}
return nums.length + 1;
}
}

从前序与中序遍历序列构造二叉树[🔥105]

思路:

//1.从前序遍历里确定根节点
//2.然后确立根节点在中序遍历的位置 index,然后从中序遍历数组中得出 左子树的长度
//3.切分中序数组,切出来左子树,右子树 eg:[9,3,15,20,7] -> [9] [15,20,7] [0,leftSize) leftSize [leftSize-1,n)
// 4.切分前序数组,切出来左子树,右子树 eg:[3,9,20,15,7] -> [1,1+leftSize) [1+leftSize,n)
//5.递归,求出left,right
//6. return TreeNode (preOrder[0],left,right)

PS:md是真的晕递归啊啊,为什么学了两年还是晕。。。。你理解递归不要陷入细节,先明白方法返回的是什么!!!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
/**
* Definition for a binary tree node.
* public class TreeNode {
* int val;
* TreeNode left;
* TreeNode right;
* TreeNode() {}
* TreeNode(int val) { this.val = val; }
* TreeNode(int val, TreeNode left, TreeNode right) {
* this.val = val;
* this.left = left;
* this.right = right;
* }
* }
*/
class Solution {
public TreeNode buildTree(int[] preorder, int[] inorder) {
//[3,9,20,15,7]
//[9,3,15,20,7]

//1.从前序遍历里确定根节点
//2.然后确立根节点在中序遍历的位置 index
//3.切分中序数组,切出来左子树,右子树 eg:[9,3,15,20,7] -> [9] [15,20,7] [0,leftSize) leftSize [leftSize-1,n)
// 4.切分前序数组,切出来左子树,右子树 eg:[3,9,20,15,7] -> [1,1+leftSize) [1+leftSize,n)
//5.递归,求出left,right
//6. return TreeNode (preOrder[0],left,right)
int n = preorder.length;
if (n == 0) {
return null;
}
//
int leftSize = getLeftSize(inorder, preorder[0]);
int[] pre1 = Arrays.copyOfRange(preorder, 1, 1 + leftSize);
int[] pre2 = Arrays.copyOfRange(preorder, 1 + leftSize, n);
//[0 leftSize-1] [leftSize+1 n]
int[] in1 = Arrays.copyOfRange(inorder, 0, leftSize);
int[] in2 = Arrays.copyOfRange(inorder, 1 + leftSize, n);
TreeNode left = buildTree(pre1, in1);
TreeNode right = buildTree(pre2, in2);
return new TreeNode(preorder[0], left, right);
}

public int getLeftSize(int[] order, int x) {
for (int i = 0;; i++) {
if (order[i] == x) {
return i;
}
}
}
}

子集[99]

思路:

  1. 回溯三部曲

做的时候脑子要有回溯树

1
2
3
4
5
6
7
8
9
10
               []                      (startIndex=0)
/ | \
[1] [2] [3] (分别选择 1,2,3)

/ \ / \ \
[1,2] [1,3] [2,3] ··· ··· (继续往右走)

/
[1,2,3] (完整路径)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class Solution {
List<Integer> path = new ArrayList<>();
List<List<Integer>> res = new ArrayList<>();

public List<List<Integer>> subsets(int[] nums) {
if (nums.length == 0) {
return res;
}
dfs(nums, 0);
return res;

}
//startIndex 的作用是控制
// startIndex 的本质是:

// 限制递归的选择范围(只能往后,不回头)。

// 保证子集不重复(避免 [2,1] 这种情况)。
public void dfs(int[] nums, int startIndex) {
res.add(new ArrayList<>(path));
for (int i = startIndex; i < nums.length; i++) {
path.add(nums[i]);
dfs(nums, i + 1);
path.remove(path.size() - 1);
}
}
}

时间复杂度 O(N*N)

在排序数组中查找元素的第一个和最后一个位置[93]

思路:

  1. 二分找左右边界
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
class Solution {
public int[] searchRange(int[] nums, int target) {
//二分查找
int leftBorder=leftBorder(nums,target);
int rightBorder=rightBorder(nums,target);
if(leftBorder==-1&&rightBorder==-1){
return new int[]{leftBorder,rightBorder};
}
else
return new int[]{leftBorder+1,rightBorder-1};

}
public int leftBorder(int []nums,int target){
int left=0;
int right=nums.length-1;//左闭右闭
int leftBorder=-1;
while(left<=right){
int mid=left+(right-left)/2;
if(nums[mid]==target){
right=mid-1;
leftBorder=right;
}
else if(nums[mid]<target){
left=mid+1;
}
else if(nums[mid]>target){
right=mid-1;
}
}
return leftBorder;
}
public int rightBorder(int []nums,int target){
int left=0;
int right=nums.length-1;//左闭右闭
int rightBorder=-1;
while(left<=right){
int mid=left+(right-left)/2;
if(nums[mid]==target){
left=mid+1;
rightBorder=left;
}
else if(nums[mid]<target){
left=mid+1;
}
else if(nums[mid]>target){
right=mid-1;
}
}
return rightBorder;
}
}

组合总和[86]

题目:给一个数组,然后给一个target,让你求和等于target的 数组里的数字组合,每个数字可重复取

思路:

  1. 回溯
  2. 重复取怎么处理呢 dfs(candidates,target,i);不是i+1, i就表示可以重复取。然后用sum>target来控制 超过就return
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Solution {
List<Integer> path = new ArrayList<>();
List<List<Integer>> res = new ArrayList<>();
int sum =0;//用来记录cur和
public List<List<Integer>> combinationSum(int[] candidates, int target) {
dfs(candidates,target,0);
return res;
}
public void dfs(int []candidates,int target,int startIndex){
if(sum == target){
res.add(new ArrayList<>(path));
return ;
}
for(int i=startIndex;i<candidates.length;i++){
if(sum > target) break;
sum += candidates[i];
path.add(candidates[i]);
//这里的i表示 每个数可以重复选取
dfs(candidates,target,i);
sum -= path.remove(path.size()-1);
}
}
}

要考虑去重吗?

最小路径和[86]

题目

思路:

  1. 多条路径求最小和的,可以考虑dfs算法,但很遗憾,时间复杂度2的m+n 次方,超时了
  2. 动态规划。理清数组元素含义,以及当前状态是怎么由前面的状态推导过来的

dfs:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class Solution {
int res = Integer.MAX_VALUE;
int sum = 0;
int n, m;
int[][] dir = {{0,1},{1,0}}; // 只需要右和下

public int minPathSum(int[][] grid) {
n = grid.length;
m = grid[0].length;
dfs(grid, 0, 0, 0);
return res;
}

public void dfs(int[][] grid, int x, int y, int currentSum) {
if(x >= n || y >= m) return; // 越界
currentSum += grid[x][y];
if(x == n-1 && y == m-1) {
res = Math.min(res, currentSum);
return;
}
dfs(grid, x+1, y, currentSum); // 下
dfs(grid, x, y+1, currentSum); // 右
}
}

动态规划

美团一面,写完要求在源码基础上生成路径?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public int minPathSum(int[][] grid) {
int n = grid.length;
int m = grid[0].length;

int[][] dp = new int[n][m]; // dp[i][j] = 到 (i,j) 的最小路径和
String[][] path = new String[n][m]; // path[i][j] = 到 (i,j) 的路径字符串

dp[0][0] = grid[0][0];
path[0][0] = "(0,0)";

// 初始化第一列
for (int i = 1; i < n; i++) {
dp[i][0] = dp[i-1][0] + grid[i][0];
path[i][0] = path[i-1][0] + " -> (" + i + "," + 0 + ")";
}

// 初始化第一行
for (int j = 1; j < m; j++) {
dp[0][j] = dp[0][j-1] + grid[0][j];
path[0][j] = path[0][j-1] + " -> (" + 0 + "," + j + ")";
}

// 填表
for (int i = 1; i < n; i++) {
for (int j = 1; j < m; j++) {
//下走 还是 左走
if (dp[i-1][j] < dp[i][j-1]) {
dp[i][j] = dp[i-1][j] + grid[i][j];
path[i][j] = path[i-1][j] + " -> (" + i + "," + j + ")";
} else {
dp[i][j] = dp[i][j-1] + grid[i][j];
path[i][j] = path[i][j-1] + " -> (" + i + "," + j + ")";
}
}
}

System.out.println("Path: " + path[n-1][m-1]);
return dp[n-1][m-1];
}

最长连续序列[82]

题目:从一没排好序的数组里找出 最长的连续序列 如 1,2,3,4

思路:

  1. hashset,for x 然后以x为开头搜连续段,后续for 时候如果遇到连续段里的就 continue 。即哈希set,每次搜索一个数搜完一个连续段,后面在遇到连续段中的直接跳过。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class Solution {
public int longestConsecutive(int[] nums) {
Set<Integer> set = new HashSet<>();
//去重
for (int i : nums) {
set.add(i);
}
//
int res = 0;
for (int x : set) {
//已经找过的 就不要再找了
if (set.contains(x - 1))
continue;

//寻找以x为开头的连续序列
//x x+1 x+1+1 ....
int cnt = 1;
int nextX = x + 1;
while (set.contains(nextX)) {
nextX++;
cnt++;
}
res = Math.max(res, cnt);
}
return res;
}
}

时间复杂度:O N,因为每个元素只被访问了一次

旋转图像[]

思路:

  1. 先交换行(上下倒转),然后转置( 转置操作:matrix[i][j]matrix[j][i]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Solution {
public void rotate(int[][] matrix) {
int n = matrix.length - 1;
int m = matrix[0].length - 1;
//先交换行 上下倒置 第一行 和 第n 行 第二行 和 第 n-1行
for (int i = 0; i <= n/2; i++) {
int[] temp = matrix[i];
matrix[i] = matrix[n - i];
matrix[n - i] = temp;
}
//倒置 [i][j] 和 [j][i] 交换 对角想交换
for (int i = 0; i <= n; i++) {
for (int j = i + 1; j <= m; j++) {
int temp = matrix[i][j];
matrix[i][j] = matrix[j][i];
matrix[j][i] = temp;
}
}
}
}

转置:

岛屿的最大面积[80]

思路:

  1. 就是dfs,模版题,没啥好说的,需要注意一下这种情况因为是四面八方都可以走,那就得记录走过的位置或者说 for循环入口其实导向是同一块岛屿
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
class Solution {
int area = 0;
int res = 0;

public int maxAreaOfIsland(int[][] grid) {
//bfs or dfs

//dfs
for (int i = 0; i < grid.length; i++) {
for (int j = 0; j < grid[0].length; j++) {
area = 0;
dfs(grid, i, j);
res = Math.max(res, area);
}
}
return res;
}

public void dfs(int[][] grid, int x, int y) {
if (x < 0 || y < 0 || x >= grid.length || y >= grid[0].length) {
return;
}
if (grid[x][y] != 1) {
return;
}
if (grid[x][y] == 1)
area++;
grid[x][y] = -1;

dfs(grid, x + 1, y);
dfs(grid, x, y + 1);
dfs(grid, x, y - 1);
dfs(grid, x - 1, y);
}
}

寻找峰值[76]

题目:找出山峰,返回山峰的索引

思路:二分查找,分情况讨论

1
2
3
4
5
6
7
8
9
10
11
12
// [-无穷 ...3 4 .... -无穷]
// 那么4的右侧一定有山峰
// mid = indexof(3)
if (nums[mid] < nums[mid + 1]) {
l = mid + 1;
}
// [-无穷 ...4 3 .... -无穷]
// mid = indexof(3)
// 那么4的左侧一定有山峰
else {
r = mid - 1;
}

图解:162. 寻找峰值 - 力扣(LeetCode)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class Solution {
public int findPeakElement(int[] nums) {
if (nums.length == 1)
return 0;
//两端
if (nums[nums.length - 1] > nums[nums.length - 2]) {
return nums.length - 1;
}
if (nums[0] > nums[1]) {
return 0;
}
//二分法
int l = 0;
int r = nums.length - 1;
while (l <= r) {
int mid = (r + l) / 2;
// [-无穷 ...3 4 .... -无穷]
// 那么4的右侧一定有山峰
// mid = indexof(3)
if (nums[mid] < nums[mid + 1]) {
l = mid + 1;
}
// [-无穷 ...4 3 .... -无穷]
// mid = indexof(3)
// 那么4的左侧一定有山峰
else {
r = mid - 1;
}
}
return l;
}
}

乘积最大子数组[70]

多数元素[67]

思路:

  1. map
  2. 空间复杂度O(1)的解法:摩尔投票

长度最小的子数组

题目:长度最小的和为k的子数组

思路:

移动零[60]

思路:

  1. 双指针,i0,i
  2. 记录0元素的位置i0,i。如果nums[i]为非0元素,就与其交换,然后i0++
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Solution {
public void moveZeroes(int[] nums) {
//双指针
int i0 = 0;// i0表示指向
for (; i0 < nums.length; i0++) {
if (nums[i0] == 0) {
break;
}
}
//[1,2,0,5,6,0,1]
//
for (int i = i0 + 1; i < nums.length; i++) {
// 非0 元素与0元素交换
if (nums[i] != 0) {
// i0 i交换
int temp = nums[i];
nums[i] = nums[i0];
nums[i0] = temp;
// 交换过了 i0++
i0++;
}
}
}
}

寻找旋转排序数组中的最小值[56]

题目:在部分有序数组中找到最小值

思路:

  1. 看到log N的算法,就得想到二分
  2. 根据mid和nums.length元素比较 得出最小值是在右边还是在左边
  3. [3,4,5,1,2]:最小值在右边
  4. [1 2 3 4 5]:最小值在左边
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Solution {
public int findMin(int[] nums) {
//
int l = 0;
int r = nums.length - 1;
while (l <= r) {
int mid = l + (r - l) / 2;
// [3,4,5,1,2] 5 > 2 最小值在右边
// 为什么不能和nums[0] 比较? 因为无法区分 [1 2 3 4 5] [3,4,5,1,2] 这两种情况
if (nums[mid] > nums[nums.length - 1]) {
l = mid +1;
} else {
//[1 2 3 4 5] 最小值在右边
r = mid -1;
}
}

return nums[l];
}
}

先升序再降序的数组,找到最大值

参考寻找峰值

单词搜索[54]

思路:

  1. dfs+回溯
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
class Solution {
int offset = -1;
boolean[][] visited;

public boolean exist(char[][] board, String word) {
visited = new boolean[board.length][board[0].length];
for (int i = 0; i < board.length; i++) {
for (int j = 0; j < board[0].length; j++) {
if (dfs(board, i, j, word, 0)) {
return true;
}
}
}
return false;
}

public boolean dfs(char[][] board, int x, int y, String word, int offset) {
if (x < 0 || y < 0 || x >= board.length || y >= board[0].length) {
return false;
}
if (visited[x][y])
return false;

if (board[x][y] != word.charAt(offset)) {
return false;
}
if (offset == word.length() - 1)
return true;

visited[x][y] = true;

boolean found = dfs(board, x, y + 1, word, offset + 1) ||
dfs(board, x + 1, y, word, offset + 1) ||
dfs(board, x, y - 1, word, offset + 1) ||
dfs(board, x - 1, y, word, offset + 1);
//回溯
visited[x][y] = false;
return found;
}
}

分隔链表[20]

思路:

  1. 双指针,sml,big
  2. if cur < x 加入sml,else 加入big

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
/**
* Definition for singly-linked list.
* public class ListNode {
* int val;
* ListNode next;
* ListNode() {}
* ListNode(int val) { this.val = val; }
* ListNode(int val, ListNode next) { this.val = val; this.next = next; }
* }
*/
class Solution {
public ListNode partition(ListNode head, int x) {
ListNode smlDummy = new ListNode();
ListNode bigDummy = new ListNode();
ListNode smlCur = smlDummy;
ListNode bigCur = bigDummy;
ListNode cur = head;
while(cur!=null){
if(cur.val < x){
smlCur.next = cur;
smlCur = smlCur.next;
}
else {
bigCur.next = cur;
bigCur = bigCur.next;
}
cur = cur.next;
}
smlCur.next = bigDummy.next;
//重点
bigCur.next = null;
return smlDummy.next;
}
}

搜索二维矩阵 II[80]

思路:旋转45度

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Solution {
public boolean searchMatrix(int[][] matrix, int target) {
//逆旋转旋转45
int i = matrix.length - 1;
int j = 0;
while(i >= 0 && j < matrix[0].length){
if(matrix[i][j] > target){
//向左变小
i--;
}
else if(matrix[i][j] < target){
//向右变大
j++;
}
else return true;
}
return false;


}
}

乘积最大子数组[71]

思路:

  1. 暴力,两层for循环
  2. 是不可以搞前缀积的!!!
1
2
3
4
5
6
7
8
9
10
11
12
13
class Solution {
public int maxProduct(int[] nums) {
int res = Integer.MIN_VALUE;
for(int i = 0; i < nums.length; i++){
int sub = 1;
for(int j = i; j < nums.length; j++){
sub = sub * nums[j];
res = Math.max(res, sub);
}
}
return res;
}
}
  1. 动态规划

维护当前最大值和当前最小值

解释:当前最大值和当前最小值是指什么?

变量 更严谨的定义
curMax nums[i] 结尾的所有子数组里「乘积最大的值」,如果当前是 0,则为 0
curMin nums[i] 结尾的所有子数组里「乘积最小的值」,如果当前是 0,则为 0
  1. 因为当遇到负数时,要乘上负数时,当前最大值会变成当前最小值,当前最小值会变成当前最大值
  2. 如果一直遇到整数,那么不断curMax * curNum[] 就好了
  3. 如果遇到了 0,这时候就会自动断开了,curMax = 0, curMin = 0(即重开了)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class Solution {
public int maxProduct(int[] nums) {
int res = Integer.MIN_VALUE;
//当前最大值,最小值
int curMax = 1;
int curMin = 1;
for (int i = 0; i < nums.length; i++) {
//如果为负数,就会触发 最大值变成最小值 最小值变成最大值
// 1 2 -5 curMax = 2 curMin = 1
// curMax = 1 curMin = 2
// curMax = -5 curMin = -10
if (nums[i] < 0) {
int temp = curMax;
curMax = curMin;
curMin = temp;
}
curMax = Math.max(curMax * nums[i], nums[i]);
curMin = Math.min(curMin * nums[i], nums[i]);
if(nums[i] == 0){
System.out.println("---0-");
System.out.println(curMax);
System.out.println(curMin);
System.out.println("---0-");
}
System.out.println(curMax);
System.out.println(curMin);
res = Math.max(curMax, res);
}
return res;
}
}

字节腾讯,还要输出这个数组

维护bestStart bestEnd,然后如果nums[i] > curMax 就会开始重新计数了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
class Solution {
public int maxProduct(int[] nums) {
int res = Integer.MIN_VALUE;
//当前最大值,最小值
int curMax = 1;
int curMin = 1;
//结果集的起始
int start = 0, bestStart = 0, bestEnd = 0;

for (int i = 0; i < nums.length; i++) {
//如果为负数,就会触发 最大值变成最小值 最小值变成最大值
// 1 2 -5 curMax = 2 curMin = 1
// curMax = 1 curMin = 2
// curMax = -5 curMin = -10
if (nums[i] < 0) {
int temp = curMax;
curMax = curMin;
curMin = temp;
}
if (curMax * nums[i] < nums[i]) {
curMax = nums[i];
start = i;
}
else curMax = curMax * nums[i];

curMin = Math.min(curMin * nums[i], nums[i]);

if (res < curMax) {
res = curMax;
bestStart = start;
bestEnd = i;
}
}
System.out.println(Arrays.toString(Arrays.copyOfRange(nums, bestStart, bestEnd + 1)));
return res;
}
}

跳跃游戏[57]

思路:两种做法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Solution {
public boolean canJump(int[] nums) {
//i 是 人的位置,k 是路(即人能最远到达的地方),路就决定了 人能到达的位置
if(nums.length <= 1) return true;
int k = 0;
for(int i = 0; i <= k; i++){
k = Math.max(k, nums[i] + i);
if(k >= nums.length - 1){
return true;
}
}
return false;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution {
public boolean canJump(int[] nums) {
//
if(nums.length <= 1){
return true;
}
// 2 3 1 1 4
// k表示 当前能跳到的最远位置
int k = 0;
for(int i = 0; i < nums.length; i++){
if(i > k) return false;
k = Math.max(k, i + nums[i]);
System.out.println("当前下标:" + i + "最远位置:" + k);
}
return true;
}
}

跳跃游戏 II[47]

两种做法:

  1. 动态规划
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class Solution {
public int jump(int[] nums) {
if(nums.length <= 1) return nums.length - 1;
//表示 走到i的所需的最小跳跃数
int n = nums.length;
int []dp = new int[n];
Arrays.fill(dp, Integer.MAX_VALUE);
dp[0] = 0;
dp[1] = 1;
for(int i = 0; i < nums.length; i++){
//状态方程
int k = i + nums[i];
if( k > nums.length - 1){
k = nums.length - 1;
}

dp[k] = Math.min(dp[k], dp[i] + 1);
for(int j = i + 1; j < k + 1; j++){
dp[j] = Math.min(dp[k], dp[j]);
}
//Arrays.fill(dp,, dp[k]);
System.out.println(Arrays.toString(dp));

}
//System.out.println(Arrays.toString(dp));
return dp[n - 1];
}
}

时间复杂度 = O(n²),空间复杂度 = O(n)

  1. 贪心
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Solution {
public int jump(int[] nums) {
int steps = 0; // 总跳跃次数
int end = 0; // 当前这一步能跳到的最远边界
int farthest = 0; // 下一步能跳到的最远位置

// 注意:最后一个位置不需要再跳,所以循环到 n-2
for (int i = 0; i < nums.length - 1; i++) {
// 更新下一步的最远可达位置
farthest = Math.max(farthest, i + nums[i]);

// 如果到达当前步的边界,就必须跳一次
if (i == end) {
steps++;
end = farthest; // 更新下一跳的边界
}
}
return steps;
}
}

盛最多水的容器[51]

思路:

  1. 双指针
  2. 贪心思路: 木桶容量由短板决定, 移动长板的话, 水面高度不可能再上升, 而宽度变小了, 所以只有通过移动短板, 才有可能使水位上升.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Solution {
public int maxArea(int[] height) {
//双指针
int l = 0;
int r = height.length - 1;
int res = Integer.MIN_VALUE;
while(l <= r){
int h = Math.min(height[l], height[r]);
res = Math.max(res, h * (r - l));
//把矮的给淘汰掉,因为要装,那么短的那表一定得尽可能长
if(height[l] > height[r]){
r--;
}
else l++;
}
return res;
}
}

最短无序连续子数组[10]

思路:

  1. 最简单的做法,即先排序,然后比对,从左,从右,找第一个不同的元素,然后len = right - left + 1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Solution {
public int findUnsortedSubarray(int[] nums) {
int[] newNums = Arrays.copyOfRange(nums, 0, nums.length);
Arrays.sort(newNums);
int l = 0;
int r = -1;
for(int i = 0;i < newNums.length; i++){
if(newNums[i] != nums[i]){
l = i;
break;
}
}
for(int i = newNums.length - 1;i >= 0; i--){
if(newNums[i] != nums[i]){
r = i;
break;
}
}
return r - l + 1 > 0? r - l + 1: 0;
}
}

计数排序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
class Solution {
public int findUnsortedSubarray(int[] nums) {
int[] newNums = Arrays.copyOfRange(nums, 0, nums.length);
sort(newNums);
//System.out.println(Arrays.toString(newNums));
int l = 0;
int r = -1;
for(int i = 0;i < newNums.length; i++){
if(newNums[i] != nums[i]){
l = i;
break;
}
}
for(int i = newNums.length - 1;i >= 0; i--){
if(newNums[i] != nums[i]){
r = i;
break;
}
}
return r - l + 1 > 0? r - l + 1: 0;
}
public void sort(int []nums){
int MAX = 100000;
int[] counter = new int[MAX * 2 + 4];
int min = Integer.MAX_VALUE;
int max = Integer.MIN_VALUE;
for(int i: nums){
min = Math.min(i, min);
max = Math.max(i, max);
counter[i + MAX]++;
}
int idx = 0;
for(int i = min; i <= max; i++){
int count = counter[i + MAX];
while(count > 0){
nums[idx] = i;
count--;
idx++;
}
}
}
}

任务调度器[5]

思路:

  1. 贪心:很容易想到就是 先安排出现次数最多的任务,然后让这个任务之间的间隔刚好为N

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
class Solution {
public int leastInterval(char[] tasks, int n) {
//贪心:很容易想到就是 先安排出现次数最多的任务,然后让这个任务之间的间隔刚好为N
Map<Character,Integer> counter = new HashMap();
for(char c: tasks){
counter.put(c, counter.getOrDefault(c, 0) + 1);
}
int max = 0;
char cMax = '*';
for(char c: counter.keySet()){
if(counter.get(c) > max) {
cMax = c;
max = counter.get(c);
}
}
int bucketCnt = max;
int cntOflastBucket = 0;
for(char c: counter.keySet()){
if(counter.get(c) == max) cntOflastBucket++;
}
//第一种情况:槽够用
//eg:A:4 B:4 C:1 D: 1 n = 2
//A B C
//A B D
//A B *
//A B
//则 完成时间不能等于 长度

//第二种情况:槽不够用
//eg:A:4 B:4 C:4 D: 2 n = 2
//A B C D
//A B C D
//A B C
//A B C
//则 完成时间等于 长度
return Math.max((bucketCnt - 1) * (n + 1) + cntOflastBucket, tasks.length);
}
}

找到所有数组中消失的数字[4]

同缺失的第一个正数

思路:

  1. 交换到正确的位置上
  2. [1,2,3] –> nums[i] == nums[nums[i] - 1],即nums[0] == nums[1 - 1]。为什么不能写成 i=nums[i] - 1?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class Solution {
public List<Integer> findDisappearedNumbers(int[] nums) {
for(int i = 0; i < nums.length; i++){
System.out.println(i);
System.out.println(nums[i] +"::::::::::::" + nums[nums[i] - 1]);
while(nums[i] <= nums.length && nums[i] != nums[nums[i] - 1]){
System.out.println(Arrays.toString(nums));
System.out.println(i + ":::"+ (nums[i] -1));
swap(nums, i, nums[i] - 1);
}
}
//System.out.println(Arrays.toString(nums));
List<Integer> res = new ArrayList();
// 1 2 3
for(int i = 0; i < nums.length; i++){
if(nums[i] != i + 1)
res.add(i + 1);
}
return res;
}
public void swap(int []nums, int i,int j){
int temp = nums[i];
nums[i] = nums[j];
nums[j] = temp;
}
}
//eg:[2,2,1] nums[i] == nums[nums[i] - 1] 表示当前数字是否在正确位置上,每次就会正确交换一次,这样子每个数字只会被遍历一次
// 0
// 2::::::::::::2
// 1
// 2::::::::::::2
// 2
// 1::::::::::::2
// [2, 2, 1]
// 2:::0

根据身高重建队列

  1. 先排序,先按p[0] 降序 and p[1] 升序。

:::info
楼主的解释很棒!但是这里稍微有点没有解释清楚,“按照第二个元素正向排序,我们希望 k 大的尽量在后面,减少插入操作的次数。”不止是为了减少插入次数,也是为了保证正确性。
举个例子,在身高一样,k不一样的时候,譬如[5,2]和[5,3], 对于最后排完的数组,[5,2]必然在[5,3]的前面。所以如果遍历的时候[5,3]在前面,等它先插入完,这个时候它前面会有3个大于等于它的数组对,遍历到[5,2]的时候,它必然又会插入[5,3]前面(因为它会插入链表索引为2的地方),这个时候[5,3]前面就会有4个大于等于它的数组对了,这样就会出错。

:::

  1. 然后巧用 add(index,n)实现插队
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Solution {
public int[][] reconstructQueue(int[][] people) {
//先排序再插队
//排序
Arrays.sort(people,
(a, b) -> {
return a[0] == b[0] ? a[1] - b[1] : b[0] - a[0];
}
);
List<int[]> res = new ArrayList();

for(int []p: people) {
System.out.println(Arrays.toString(p));
//插队
res.add(p[1], p);
}
System.out.println("////////////");
for(int []p: res) {
System.out.println(Arrays.toString(p));
}
return res.toArray(new int[people.length][2]);
}
}

除自身以外数组的乘积[]

思路

  1. 前缀积,后缀积
  2. Map<Integer,Integer> front:k:当前元素index。v:包含当前元素的区间积,元素之前的积即map.getIOrDefault(i - 1, 1) * curNum
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import java.util.*;

class Solution {
public int[] productExceptSelf(int[] nums) {
int[] ans = new int[nums.length];
Map<Integer, Integer> front = new HashMap<>();
Map<Integer, Integer> back = new HashMap<>();

// 前缀积
for (int i = 0; i < nums.length; i++) {
front.put(i, front.getOrDefault(i - 1, 1) * nums[i]);
}

// 后缀积
for (int i = nums.length - 1; i >= 0; i--) {
back.put(i, back.getOrDefault(i + 1, 1) * nums[i]);
}

// 最终结果
for (int i = 0; i < nums.length; i++) {
int left = front.getOrDefault(i - 1, 1);
int right = back.getOrDefault(i + 1, 1);
ans[i] = left * right;
}

return ans;
}
}

会议室2[24]

1

这是一个非常高级的案例。借助这个案例,你可以达成三个效果:

  • 证明自己很擅长搞服务治理
  • 证明自己懂得解决重试风暴的问题
  • 证明自己能够设计非常复杂的方案

这个案例就是借助滑动窗口算法来统计一段时间内的超时请求比率,如果超过了比率,那么就认为此时超时不是一个偶发性的问题,不应该继续重试。而后,为了支撑高并发的场景,滑动窗口算法可以借助 ring buffer 来实现,用一个比特来表达请求是否超时。同时可以用原子操作来进一步提高性能,因此你在这个案例下能够聊的话题非常多:

  • 滑动窗口算法
  • Ring Buffer 算法
  • 位操作:统计 1 的个数
  • 原子操作

我愿称之为,你拿出去装一次逼,就可以证明你在算法设计上还是有点东西的。

在微服务超时控制和重试策略里面,经常会引用这个案例。代码在 interview-cases/case31_40/case32 at main · meoying/interview-cases (github.com)

普通版

普通版本就是使用一个普通的滑动窗口算法,关键点是:

  • 在窗口里面,记录了每个请求的时间戳,以及这个请求是否超时;
  • 每个请求在收到了超时响应之后,统计这个窗口内部的超时请求数量;
  • 如果超时请求数量超过阈值了,那么就认为不应该重试了;
  • 如果超时请求数量没有超过阈值,那么就认为可以重试;

进阶版

在高并发的情况下,滑动窗口算法的开销是难以忍受的。举个例子来说,即便将窗口限制在 1s,高并发的时候 1s 也有几万个请求,这几万个请求会占据你很多内存。而且在判定要不要重试的时候还要遍历,这也是一个极大的问题。

所以在进阶版里面,需要做优化:

  • 使用 ring buffer 来实现滑动窗口,进一步使用一个比特来标记请求是否超时;
  • 每个请求超时之后,判定要不要执行重试,就要统计这个 ring buffer 里面为 1 的比特数量;
  • 如果超时请求数量超过阈值了,那么就认为不应该重试;
  • 如果超时请求数量没有超过阈值,那么就认为可以重试;
  • 为了进一步提高性能,这里可以使用原子操作;

举个例子来说,假设我们现在用 128 个比特来记录请求的超时信息,也就是说窗口大小是 128 个请求(不再是以时间来作为窗口大小了)。而后第一个请求过来标记下标 0 的比特,第二个请求标记下标 1 的比特…当都标记满了,就再次从头开始。

要注意,在我们的实现里面使用了大量的原子操作,所以你同样可以用这个案例来证明你很擅长并发编程。如果你觉得代码奇怪,请不要惊讶,它确实是对的,只是说违背一般的并发编程的模式而已,但是它确实是并发安全的,并且性能很好。

适用场景和话术

它可以用在这些场景:

  • 介绍你的提高可用性的方案,你可以将重试作为作为提高系统可用性的一环,而后介绍你这个规避了重试风暴的重试策略;
  • 在面试中讨论到了超时问题
  • 在面试中讨论到了重试问题,你可以主动提及重试风暴的问题,以及你解决了这个问题,也就是这个案例;

而在介绍的时候,你要用一种演进的预期来介绍,也就是你不要上来就介绍进阶版,你要先介绍普通版,而后介绍进阶版。从这个演进中能够体现你对系统的思考,以及你是一个精益求精的人。

那么你可以参考这个话术:

在处理大量请求时,我们经常会遇到超时的情况。为了合理控制重试行为,避免所谓的“重试风暴”,我设计了一个基于时间窗口的算法。在这个算法中,我们维护了一个滑动窗口,窗口内记录了每个请求的时间戳以及该请求是否超时。每当一个请求超时后,我们会统计窗口内超时的请求数量。如果超时请求的数量超过了设定的阈值,我们就认为当前系统压力较大,不适合进行重试;否则,我们认为可以安全地进行重试。

然而,随着并发量的增加,普通版的滑动窗口算法暴露出了一些问题。特别是在高并发场景下,窗口内需要维护的请求数量可能非常大,这不仅占用了大量内存,而且在判定是否需要重试时还需要遍历整个窗口,这大大增加了算法的时间复杂度。

为了解决这个问题,我们进一步设计了进阶版的算法。在这个版本中,我们引入了ring buffer 来优化滑动窗口的实现。具体来说,我们不再以时间为窗口大小,而是使用固定数量的比特位来记录请求的超时信息。每个比特位对应一个请求,用1表示超时,用0表示未超时。当所有比特位都被标记后,我们从头开始再次标记。

这种设计极大地降低了内存占用,因为无论并发量多高,我们只需要固定数量的比特位来记录请求的超时状态。同时,在判定是否需要重试时,我们只需要统计ring buffer中为1的比特数量,这大大简化了算法的实现并提高了效率。

模拟面试题

你的 ring buffer 设置得多大?

你可以随便说,并不需要很多。比如说你可以这么回答:

默认情况下是 128 字节,也就是 128 * 8 比特,1024 个比特。相当于每次都是只统计 1024 个请求中超时的比例

内容

这个案例本质上也是一个榜单案例,它和另外一个榜单案例比起来:Redis 数据结构:榜单问题之本地缓存 + zset + 定时任务方案

  • 本案例强调的是 zset 存储大量的数据,实时性更好;
  • zset 拆分是 Redis 大key 拆分解决思路的一个具体体现,从技术含量上来说要更高一些;
  • 本案例要求 Redis 最好是一个 Cluster;

代码在这里:interview-cases/case21_30/case21 at main · meoying/interview-cases (github.com)

实现思路

整个实现思路很简单,假设我们分 key 之后的 key 形式是 my_biz:%d,假设我们只要榜单前 100:

  • 使用 N 个 zset 来存储所有的榜单数据。在我们的代码的例子里面默认是使用 10 个 key;
  • 根据业务主键除以分 key 的数量,得到 key 的后缀。例如说 id 为 3,那么它的数据在 my_biz:3 这个 key 对应的 zset 上;
  • 定时任务每 1 分钟会从这 N 个 key 里面各取前 100,而后借助归并排序计算全局的前 100。用比喻来说,就好比有 10 个班,我每个班取前 100 名,而后排序再取前 100 名,这 100 名就是全级排名;
  • 定时任务会把这 top100 的数据同步到各个节点的本地缓存中
  • 查询前 100 会直接命中本地缓存。

如图:

这里有一些细节要注意:

  • 在更新的时候要注意顺序问题。举个例子来说,如果针对同一个数据,一个要更新热度为 100,一个要更新热度为 101,你要小心并发问题。最好就是在 ZADD 的命令里面再加上一个 GT 选项;
  • zset 并不是真的要存储全部的数据,你可以只存储一部分,这个要业务来判定。比如说业务上说的是计算七天内的榜单数据,例如说只有七天内的文章才可以上榜,那么你的 zset 里面就只需要维护七天内的数据;
  • 分 key 有很多种分发。在代码例子中用的是哈希来分 key,但是实际上你可以用日期来分。比如说每天一个 key,这样在只需要七天数据参与排行的情况下,可以等 key 自然过期。例如说设置 key 的过期时间是七天,那么七天之后这个 key 就不在了,你也不需要访问了;
  • 从理论上来说,这 10 个 key 应该在经过 CRC16 计算和槽映射之后,应该均匀分散在 Redis Cluster 不同集群上;
  • 当新的业务节点上线的时候,再次计算一下全局前 100,而后这个新节点才能对外提供服务;

从性能角度来说,最主要的就是直接命中了本地缓存。

从高可用的角度来说,即便整个 Redis 集群全崩了,也可以靠着本地缓存撑住。

适用场景

通常来说,你在简历里写擅长设计高性能,高可用的缓存方案或者设计过高性能高可用的榜单方案, 就可以用这个案例作为证据。遇到这些问题你就可以用这个案例来回答:

  • 你的项目有什么难点?
  • 你做过什么令人印象深刻的事情?
  • 你觉得你做得最好的点是什么?

如果面试官问到了有关缓存、Redis、Redis zset 这些问题,你也可以使用这个案例

  • 你有设计过缓存方案吗?这个方案总的来说还是很强的
  • 你用过 zset 吗?

榜单问题本身就是一个面试热点,所以这个问题也能显著帮到你,换句话来说,只要面试官问你怎么解决排行榜之类的问题,你就可以用这个案例。

可以参考以下话术来使用这个案例,这个案例是站在一个演进的角度来阐述的:

我设计过一个解决大数据、高可用、高性能的榜单方案。一开始我们业务的数据量不是很大,所以直接用了最简单的 Redis zset 来计算榜单。

后面随着业务的发展,计算这个榜单的数据越来越多,并发量越来越高。这个时候,一个 zset 里面要放几百万个数据,存储这个 zset 的 Redis 并发极高,压力极大。并且 zset 中元素数量太多,导致更新的时候越来越慢。

发现这个问题之后,我就综合业务实际情况,设计了一个新的榜单解决方案 —— 拆分 key 的方案。

首先,我根据数据量,将原本单一的 key 拆分成了 10 个key,比如说 my_biz:0, my_biz:1 这种。

其次,key 的后缀是业务 ID 除以 10 的余数,在更新热度可以只更新对应的 key。

第三,启动一个定时任务,这个定时任务会定期从所有的 key 里面各取前 100 名,而后用归并排序计算出来全局的前 100 名

第四,定时任务还会把计算的结果同步到所有的节点的本地缓存上。

最终业务查询榜单的时候,就直接命中本地缓存,性能极好。

模拟面试题

为什么你这里定时任务间隔是 1 分钟?

这其实是一个经验值,主要是根据产品要求来确定的。如果产品说这个榜单三分钟刷新一次,那么设置为 3 分钟都可以。如果要是产品说实时查询,就不能用定时任务了,只能直接查询 zset。在这种情况下,需要考虑扩大 Redis 集群,不然撑不住高并发。

这个案例实际上还是相当高端的。但是这个高端不是说技术难度很高,而是说思路很清晰,属于花小钱办大事的典型解决方案。

一方面,你可以把这个当做你的项目难点;另外一方面来说,你也可以在谈及 Redis zset 的时候引用这个案例。当然了,如果要是面试官问你做过啥有挑战性的事情,也可以用这个回答。

总的来说,这个案例用在初中级岗位面试中,效果非常强,能帮你建立极大的竞争优势。你在复习的时候要多琢磨这个案例之中的细节。

内容

这个案例对应的是大数据、高性能、高并发榜单的解决方案

代码在这里:interview-cases/case21_30/case21 at main · meoying/interview-cases (github.com)

实现思路

整个实现的要点:

  1. 定时任务计算全局的1000名,假设每个小时计算一次。那么即便有突发热点,那么最多一个小时就能看出来;
  2. 将1000名定时写入到 Redis,用 zset 实时维护。写入的时候删除原有的key然后将1000名写入。这一步是为了尽可能保证榜单数据准确。
  3. 再起一个定时任务,每一分钟将redis中的前100名(也就是1000中的前100,有可能不是全局的前100)同步到所有节点的本地缓存上,这一步是为了访问效率
  4. 从本地读取榜单数据

整个过程如下图:

这里稍微解释一下这个案例的思路:

首先定时任务取前 1000 条,是基于这么一个假设:即这一小时内,top 100 绝大概率就是从这 1000 中选出来。如果你觉得 1000 这个太少,那么可以扩展到 10000。如果你打算进一步扩展到前 10w,那么就要考虑 Redis 分 key 了,不然会有大 key 问题。

那么会不会有例外呢?肯定会有的,比如说某个排名 1200 的热度激增,导致瞬间闯进去前 100,那么在下一次计算前 1000 的时候,它没有机会被发现。但是我们并不在意这点不精确。而且,这种不精准可以通过调低定时任务的间隔来改善。例如说改为每 10 分钟执行一次,就几乎不太可能能耐

其次则是 Redis 的 zset 主要就是为了能够尽可能精准维护这 top 100的。当然,这一步是可以去掉的,也就是只依赖定时任务每隔一段时间利用数据库的数据直接计算出来前 100。

这里还有一些细节要注意:

  1. 在使用 zset ZADD 命令的时候,必须要带上 XX。这样可以保证 zset 不会增加额外的元素
  2. 在更新的时候要注意顺序问题。举个例子来说,如果针对同一个数据,一个要更新热度为 100,一个要更新热度为 101,你要小心并发问题。最好就是在 ZADD 的命令里面再加上一个 GT 选项;
  3. 如果更新热度本身也是一个高并发,那么可以在更新接口之前插入一个 Kafka 操作,也就是将更新操作丢进去 Kafka 中,消费者从里面取出来再慢慢更新热度。而且消费者可以进一步叠加批量消费这种手段来优化更新性能;

.

适用场景

通常来说,你在简历里写擅长设计高性能,高可用的缓存方案或者设计过高性能高可用的榜单方案, 就可以用这个案例作为证据。遇到这些问题你就可以用这个案例来回答:

  • 你的项目有什么难点?
  • 你做过什么令人印象深刻的事情?
  • 你觉得你做得最好的点是什么?

如果面试官问到了有关缓存、Redis、Redis zset 这些问题,你也可以使用这个案例

  • 你有设计过缓存方案吗?这个方案总的来说还是很强的
  • 你用过 zset 吗?

榜单问题本身就是一个面试热点,所以这个问题也能显著帮到你,换句话来说,只要面试官问你怎么解决排行榜之类的问题,你就可以用这个案例。

可以参考以下话术来使用这个案例,这个案例是站在一个演进的角度来阐述的:

我设计过一个解决大数据、高可用、高性能的榜单方案。一开始我们业务的数据量不是很大,所以直接用了最简单的 Redis zset 来计算榜单。

后面随着业务的发展,计算这个榜单的数据越来越多,并发量越来越高,并且还引入了分库分表。这个时候,一个 zset 里面要放几百万个数据,存储这个 zset 的 Redis 并发极高,压力极大。并且 zset 中元素数量太多,导致更新的时候越来越慢。

发现这个问题之后,我就综合业务实际情况,设计了一个新的榜单解决方案。

首先每个小时计算一下全局前1000名数据写入到 Redis 中,用 zset 保存好。这 1000 个是覆盖式更新,也就是原本的 1000 会被删除,而后假如新的 1000 元素。

其次,如果收到新的更新请求,就使用 ZADD XX 来更新数据,并且确保 zset 内始终只有 1000 个元素。

最后,我再使用定时任务,每分钟计算一次 top100,同步到各个节点的本地缓存上。本地缓存本身是永不过期的,这样即便整个机制崩坏,但是还可以从本地缓存中读取到榜单数据,最多就是本地缓存中的榜单数据迟迟没有更新而已。

当然,两个定时任务的间隔都是可以调整的,前 1000 名这个也是可以调整的,可以根据业务需要来调整。

在这种改造之下,先是解决了 zset 引发的大 key 问题和热点问题,其次是大幅度提高了榜单查询的性能。当然缺点就是榜单的实时性下降了,但是这都是可以接受的。

模拟面试题

你提到要将前 100 同步到所有节点的本地缓存上,怎么做?

你可以参考这个问题:怎么同时更新所有节点上的本地缓存?

我在这里用的方案是定时刷新。也就是每个整分钟(例如说 00:01:00, 00:02:00)每个节点都会查询 Redis,获得最新的 top100 的数据。因为几乎是同时查询 top100,所以大家获得的 top 100 数据都是一样的。

为什么你从数据库中只计算前 1000,而不是 2000 或者 3000?

这实际上就是一个经验值。也就是说根据我的观察,我发现在一段时间内,前 100 名大概率出自前 1000 名,很少有例外,所以选 1000 就够了。当然选 2000,3000 其实问题也不大。

严格来说,只要使用 Redis zset 的时候没有大 key 问题,那么这个数字是可以尽可能大的。但是从业务上来说犯不着,因为排名靠后的那些数据,很难说短时间就变到前 100 了。

为什么你从数据库中取前 1000 是间隔一小时?间隔 30 分钟行不行?

1 小时也是一个经验值。从理论上来说,只要间隔时间大于计算前 1000 的时间就可以了。

而间隔一小时可以保证不会频繁计算,也可以保证榜单的实时性处在一种比较好的状态。

大纲

整体架构

模块

三个模块

core

protosupport

transport

1. Core 核心层

Core 核心层是 Netty 最精华的内容,它提供了底层网络通信的通用抽象和实现,包括可扩展的事件模型、通用的通信 API、支持零拷贝的 ByteBuf 等。

2. Protocol Support 协议支持层

协议支持层基本上覆盖了主流协议的编解码实现,如 HTTP、SSL、Protobuf、压缩、大文件传输、WebSocket、文本、二进制等主流协议,此外 Netty 还支持自定义应用层协议。Netty 丰富的协议支持降低了用户的开发成本,基于 Netty 我们可以快速开发 HTTP、WebSocket 等服务。

3. Transport Service 传输服务层

传输服务层提供了网络传输能力的定义和实现方法。它支持 Socket、HTTP 隧道、虚拟机管道等传输方式。Netty 对 TCP、UDP 等数据传输做了抽象和封装,用户可以更聚焦在业务逻辑实现上,而不必关系底层数据传输的细节。

逻辑结构

网络通信层

主要负责网络事件的监听,当网络数据读取到内核缓冲区后,会触发各种网络事件,会被注册到事件调度层进

行处理

网络通信层的核心组件包含BootStrap、ServerBootStrap、Channel三个组件。

- **<font style="color:rgb(59, 67, 81);">BootStrap &ServerBootStrap 引导  

如下图所示,Netty 中的引导器共分为两种类型:一个为用于客户端引导的 Bootstrap,另一个为用于服务端引导的 ServerBootStrap**,它们都继承自抽象类 AbstractBootstrap。
ServerBootStrap用于服务器启动时绑定本地端口,会绑定两个eventloopgroup,一个boss,一个worker。而BootStrap主要是用与服务器进行通信,只有一个eventloopgroup
- channel
Channel 的字面意思是“通道”,它是网络通信的载体,说白了就是操作内核缓冲区或者说我们
可以使用channel的API来操作底层Socket

channel还有不同的状态,这些不同的状态就对应的不同的回调事件

事件调度层

事件调度层的职责是通过 Reactor 线程模型对各类事件进行聚合处理,通过 Selector 主循环线程集成多种事件( I/O 事件、信号事件、定时事件等),实际的业务处理逻辑是交由服务编排层中相关的 Handler 完成。

事件调度层的核心组件包括 EventLoopGroup、EventLoop

这group其实就是线程池来着,eventloop就是线程

- eventloopgroup和eventloop和channel的关系  


特别地说一下,channel被建立后,就会分配一个eventloop与其绑定,且可不是绑死的,可多次松绑or绑定

一个 Channel 在它的生命周期里,只能绑定到一个 EventLoop。

一个 EventLoop 可以同时绑定多个 Channel,并处理它们的 I/O 事件

然后eventloop通过操作系统的epoll监听多个channel的事件,事件复杂度接近 O (1)

·类关系

EventLoopGroup 是 Netty 的核心处理引擎,那么 EventLoopGroup 和之前课程所提到的 Reactor 线程模型到底是什么关系呢?其实 EventLoopGroup 是 Netty Reactor 线程模型的具体实现方式,Netty 通过创建不同的 EventLoopGroup 参数配置,就可以支持 Reactor 的三种线程模型:

    1. **<font style="color:rgb(59, 67, 81);">单线程模型</font>**<font style="color:rgb(59, 67, 81);">:EventLoopGroup 只包含一个 EventLoop,Boss 和 Worker 使用同一个EventLoopGroup;</font>
    2. **<font style="color:rgb(59, 67, 81);">多线程模型</font>**<font style="color:rgb(59, 67, 81);">:EventLoopGroup 包含多个 EventLoop,Boss 和 Worker 使用同一个EventLoopGroup;</font>
    3. **<font style="color:rgb(59, 67, 81);">主从多线程模型</font>**<font style="color:rgb(59, 67, 81);">:EventLoopGroup 包含多个 EventLoop,Boss 是主 Reactor,Worker 是从 Reactor,它们分别使用不同的 EventLoopGroup,主 Reactor 负责新的网络连接 Channel 创建,然后把 Channel 注册到从 Reactor。</font>

服务编排层

服务编排层的职责是负责组装各类服务,它是 Netty 的核心处理链,用以实现网络事件的动态编排和有序传播。

服务编排层的核心组件包括 ChannelPipeline、ChannelHandler、ChannelHandlerContext。

- **ChannelPipeline**   

ChannelPipeline 是 Netty 的核心编排组件,**负责组装各种 ChannelHandler。**其实就是把channelHandler给串联起来,本质是一个双向链表。当网络事件来的时候,就会依次调用channelPipeline里的Handler对其进行拦截

ChannelPipeline 是线程安全的,因为每一个新的 Channel 都会对应绑定一个新的 ChannelPipeline。一个 ChannelPipeline 关联一个 EventLoop,一个 EventLoop 仅会绑定一个线程。

ChannelPipeline 中包含入站 ChannelInboundHandler 和出站 ChannelOutboundHandler 两种处

理器,我们结合客户端和服务端的数据收发流程来理解 Netty 的这两个概念

- **<font style="color:rgb(59, 67, 81);">ChannelHandler & ChannelHandlerContext</font>**

为啥每个channelHandler都要绑定一个**ChannelHandlerContext?**

有点类似于工人和工具工位的关系

ChannelHandlerContext 用于保存 ChannelHandler 上下文,通过 ChannelHandlerContext 我们可以知道 ChannelPipeline 和 ChannelHandler 的关联关系。ChannelHandlerContext 可以实现 ChannelHandler 之间的交互,ChannelHandlerContext 包含了 ChannelHandler 生命周期的所有事件,如 connect、bind、read、flush、write、close 等。此外,你可以试想这样一个场景,如果每个 ChannelHandler 都有一些通用的逻辑需要实现,没有 ChannelHandlerContext 这层模型抽象,你是不是需要写很多相同的代码呢?

1
2
3
4
5
6
7
8
public class MyHandler extends ChannelInboundHandlerAdapter {
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
System.out.println("收到消息: " + msg);
ctx.fireChannelRead(msg); // 传递给下一个 Handler
}
}

整体流程

服务端启动要干什么

配置线程池

Channel 初始化

端口绑定

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 检测是否使用Epoll优化性能
// 启动Netty服务器
@SneakyThrows(InterruptedException.class)
@Override
public void start() {
if (!start.compareAndSet(false, true)) return;
// 配置服务器参数,如端口、TCP参数等
serverBootstrap
.group(eventLoopGroupBoss, eventLoopGroupWorker)
.channel(SystemUtil.useEpoll() ? EpollServerSocketChannel.class : NioServerSocketChannel.class)
.option(ChannelOption.SO_BACKLOG, 1024) // TCP连接的最大队列长度
.option(ChannelOption.SO_REUSEADDR, true) // 允许端口重用
.option(ChannelOption.SO_KEEPALIVE, true) // 保持连接检测
.childOption(ChannelOption.TCP_NODELAY, true) // 禁用Nagle算法,适用于小数据即时传输
.childOption(ChannelOption.SO_SNDBUF, 65535) // 设置发送缓冲区大小
.childOption(ChannelOption.SO_RCVBUF, 65535) // 设置接收缓冲区大小
.localAddress(new InetSocketAddress(config.getPort())) // 绑定监听端口
.childHandler(new ChannelInitializer<>() { // 定义处理新连接的管道初始化逻辑
@Override
protected void initChannel(Channel ch) throws Exception {
ch.pipeline().addLast(
new HttpServerCodec(), // 处理HTTP请求的编解码器
new HttpObjectAggregator(config.getNetty().getMaxContentLength()), // 聚合HTTP请求
new HttpServerExpectContinueHandler(), // 处理HTTP 100 Continue请求
new NettyHttpServerHandler(nettyProcessor) // 自定义的处理器
);
}
});
serverBootstrap.bind().sync();
ResourceLeakDetector.setLevel(ResourceLeakDetector.Level.ADVANCED);
log.info("gateway startup on port {}", this.config.getPort());
}

eventloop精髓

网络框架的设计离不开 I/O 线程模型,线程模型的优劣直接决定了系统的吞吐量、可扩展性、安全性等

三种 Reactor 线程模型

主从

前两种就是建立和业务处理没分开,无法轻松建立大量连接,建立连接会被耗时的业务请求影响到

Netty EventLoop 实现原理

在 Netty 中 EventLoop 可以理解为 Reactor 线程模型的事件处理引擎,每个 EventLoop 线程都维护一个 Selector 选择器和任务队列 taskQueue。它主要负责处理 I/O 事件、普通任务和定时任务。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
/**
* EventLoop 主循环
* 不停地轮询 I/O 事件 + 执行任务队列(普通任务/定时任务)
*/
protected void run() {
// 死循环,直到 EventLoop 被关闭
for (;;) {
try {
try {
// 根据策略决定如何调用 select()(I/O 多路复用)
// selectStrategy 会返回 CONTINUE/BUSY_WAIT/SELECT
switch (selectStrategy.calculateStrategy(selectNowSupplier, hasTasks())) {
case SelectStrategy.CONTINUE:
// CONTINUE 表示继续下一次循环,不阻塞
continue;
case SelectStrategy.BUSY_WAIT:
case SelectStrategy.SELECT:
// 轮询 I/O 事件
// wakenUp.getAndSet(false) 用来标记是否需要立即唤醒
select(wakenUp.getAndSet(false));

// 如果在 select 期间又被要求唤醒,则调用 wakeup()
if (wakenUp.get()) {
selector.wakeup();
}
default:
// 其他情况不处理
}
} catch (IOException e) {
// 如果 select 出现 IO 异常,重建 selector
rebuildSelector0();
// 打印/处理异常
handleLoopException(e);
// 跳过这次循环,继续下一次
continue;
}

// 已取消的 SelectionKey 数量清零
cancelledKeys = 0;
// 是否需要再次 select 清零
needsToSelectAgain = false;

// I/O 与任务执行时间比例(默认 50:50)
final int ioRatio = this.ioRatio;

if (ioRatio == 100) {
// ioRatio=100,表示只处理 I/O,再统一处理任务
try {
processSelectedKeys(); // 处理就绪的 I/O 事件
} finally {
runAllTasks(); // 处理所有任务(普通/定时)
}
} else {
// ioRatio < 100,需要在 I/O 与任务之间分配时间
final long ioStartTime = System.nanoTime();
try {
processSelectedKeys(); // 处理就绪的 I/O 事件
} finally {
// 计算本轮 I/O 花费的时间
final long ioTime = System.nanoTime() - ioStartTime;
// 按 ioRatio 分配执行任务的时间
runAllTasks(ioTime * (100 - ioRatio) / ioRatio);
}
}

} catch (Throwable t) {
// 捕获所有异常,避免循环退出
handleLoopException(t);
}

try {
// 如果正在关闭
if (isShuttingDown()) {
// 关闭所有 Channel
closeAll();
// 确认是否可以安全关闭
if (confirmShutdown()) {
return; // 安全关闭后退出循环
}
}
} catch (Throwable t) {
// 关闭过程出现异常,打印处理
handleLoopException(t);
}
}
}

  • select(…) 👉 负责 I/O 事件的监听(类似 Selector.select())。
  • processSelectedKeys() 👉 处理就绪的 I/O 事件(读/写/连接)。
  • runAllTasks() 👉 处理任务队列里的任务(用户提交的 Runnable、定时任务等)。
  • ioRatio 👉 控制 I/O 与任务的时间分配。
  • isShuttingDown()/confirmShutdown() 👉 支持优雅关闭。

事件处理机制

对于eventloop来说就是无锁串行化,不同eventloop之间是不会有交集的

**一个 ****Channel** 在注册到某个 EventLoop 之后,它的整个生命周期都只会由这个 EventLoop 负责

结合 Netty 的整体架构,我们一起看下 EventLoop 的事件流转图,以便更好地理解 Netty EventLoop 的设计原理。NioEventLoop 的事件处理机制采用的是无锁串行化的设计思路

  • BossEventLoopGroup WorkerEventLoopGroup 包含一个或者多个 NioEventLoop。BossEventLoopGroup 负责监听客户端的 Accept 事件,当事件触发时,将事件注册至 WorkerEventLoopGroup 中的一个 NioEventLoop 上。每新建一个 Channel, 只选择一个 NioEventLoop 与其绑定。所以说 Channel 生命周期的所有事件处理都是线程独立的,不同的 NioEventLoop 线程之间不会发生任何交集。
  • NioEventLoop 完成数据读取后,会调用绑定的 ChannelPipeline 进行事件传播,ChannelPipeline 也是线程安全的,数据会被传递到 ChannelPipeline 的第一个 ChannelHandler 中。数据处理完成后,将加工完成的数据再传递给下一个 ChannelHandler,整个过程是串行化执行,不会发生线程上下文切换的问题。

NioEventLoop 无锁串行化的设计不仅使系统吞吐量达到最大化,而且降低了用户开发业务逻辑的难度,不需要花太多精力关心线程安全问题。虽然单线程执行避免了线程切换,但是它的缺陷就是不能执行时间过长的 I/O 操作,一旦某个 I/O 事件发生阻塞,那么后续的所有 I/O 事件都无法执行,甚至造成事件积压。在使用 Netty 进行程序开发时,我们一定要对 ChannelHandler 的实现逻辑有充分的风险意识。

NioEventLoop 线程的可靠性至关重要,一旦 NioEventLoop 发生阻塞或者陷入空轮询,就会导致整个系统不可用。在 JDK 中, Epoll 的实现是存在漏洞的,即使 Selector 轮询的事件列表为空,NIO 线程一样可以被唤醒,导致 CPU 100% 占用。这就是臭名昭著的 JDK epoll 空轮询的 Bug。Netty 作为一个高性能、高可靠的网络框架,需要保证 I/O 线程的安全性。那么它是如何解决 JDK epoll 空轮询的 Bug 呢?实际上 Netty 并没有从根源上解决该问题,而是巧妙地规避了这个问题。

netty如何规避的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
long time = System.nanoTime();

if (time - TimeUnit.MILLISECONDS.toNanos(timeoutMillis) >= currentTimeNanos) {

selectCnt = 1;

} else if (SELECTOR_AUTO_REBUILD_THRESHOLD > 0 &&

selectCnt >= SELECTOR_AUTO_REBUILD_THRESHOLD) {

selector = selectRebuildSelector(selectCnt);

selectCnt = 1;

break;

}

Netty 提供了一种检测机制判断线程是否可能陷入空轮询,具体的实现方式如下:

  1. 每次执行 Select 操作之前记录当前时间 currentTimeNanos。
  2. time - TimeUnit.MILLISECONDS.toNanos(timeoutMillis) >= currentTimeNanos,如果事件轮询的持续时间大于等于 timeoutMillis,那么说明是正常的,否则表明阻塞时间并未达到预期,可能触发了空轮询的 Bug。
  3. Netty 引入了计数变量 selectCnt。在正常情况下,selectCnt 会重置,否则会对 selectCnt 自增计数。当 selectCnt 达到 SELECTOR_AUTO_REBUILD_THRESHOLD(默认512) 阈值时,会触发重建 Selector 对象。

Netty 采用这种方法巧妙地规避了 JDK Bug。异常的 Selector 中所有的 SelectionKey 会重新注册到新建的 Selector 上,重建完成之后异常的 Selector 就可以废弃了。

背景:Selector 的空轮询 bug

  • 在 JDK 的部分版本里,<font style="color:rgb(59, 67, 81);">Selector.select(timeout)</font> 存在 bug:
    即使没有任何事件,它也可能立即返回,没有正常阻塞到超时时间。
  • 结果就是:
    EventLoop 线程会疯狂地“空转”,CPU 飙升到 100%,但又没干实事。

👉 如果发现 <font style="color:rgb(59, 67, 81);">select()</font>连续过早返回 512 次(怀疑 JDK 的 Selector 空轮询 bug),就会 销毁旧 Selector,重建一个新的,从而避免 EventLoop 陷入死循环空转。

正常情况:无事件会在阻塞一段时间,异常情况:立即返回

任务处理机制

  • 处理普通任务

Netty 高性能内存管理设计

认识jemalloc

前言

netty的内存管理也是参考jemalloc的设计。

内存分配器:jemalloc,temalloc,ptmalloc

他们都有一个目的即:提高内存分配回收的效率,以及尽可能地减少内存碎片

内存碎片:

Linux会把物理内存分配为一个个4kb的内存页,

物理内存的分配和回收都是基于 Page 完成的,而页里面产生的碎片成为内部碎片,而页与页之间

叫做外部碎片

内存分配算法

三种。动态分配,伙伴分配,slab

动态分配

动态分配DMA

会用一个链表来维护空闲内存块,程序申请内存时就从这个链表里面挑选

  • 内存就像一个大仓库/超市
  • 空闲分区(free block) = 货架上的空篮子(还没被进程用的内存)。
  • 进程的请求(malloc 申请内存) = 顾客来装东西(要一块内存)。
  • 分配策略(first fit / next fit / best fit) = 超市员工怎么选篮子给你。

三种算法

伙伴算法

伙伴算法就是把内存分成 2 的幂次方大小的块,按需拆分、按对合并,分配快、回收快,但会浪费一些空间(内部碎片)。

比如你分配17kb的,就得申请32kb的

slab算法

Linux 内核使用的就是 Slab 算法

jemalloc架构设计

netty的实现

基本使用

设计原则

Netty 高性能的内存管理也是借鉴 jemalloc 实现的

内存分规格

tiny,smaller,normal,huge

当想要申请的内存大小大于huge时,netty是会采用非池化的手段

netty的核心组件

可以看做是jemalloc的Java版实现

PoolArena也就是jemalloc的arena,

1
2
3
4
5
6
Class PoolArena{
smallSubpagePools // 用来存放比基本单位page 8k小的内存块
tinySubpagePools //同上
PoolChunkList
}

采用固定数量的多个 Arena 进行内存分配,Arena的数量跟CPU核数有关。当线程申请时,就会给它分配一个Arena,且在这个线程声明周期内,线程只会更这个Arena打交道

Netty 借鉴了 jemalloc 中 Arena 的设计思想,采用固定数量的多个 Arena 进行内存分配,Arena 的默认数量与 CPU 核数有关,通过创建多个 Arena 来缓解资源竞争问题,从而提高内存分配效率。线程在首次申请分配内存时,会通过 round-robin 的方式轮询 Arena 数组,选择一个固定的 Arena,在线程的生命周期内只与该 Arena 打交道,所以每个线程都保存了 Arena 信息,从而提高访问效率。

PoolChunk:

PoolArena 的数据结构包含两个 PoolSubpage 数组和六个 PoolChunkList,两个 PoolSubpage 数组分别存放 Tiny 和 Small 类型的内存块,六个 PoolChunkList 分别存储不同利用率的 Chunk,构成一个双向循环链表。

PoolArena 对应实现了 Subpage 和 Chunk 中的内存分配,其 中 PoolSubpage 用于分配小于 8K 的内存,PoolChunkList 用于分配大于 8K 的内存

随着PoolChunk的使用率变化,PoolChunk会在不同的PoolChunkList之间移动

分配流程

内存回收

面试回答

首先说下它整体是参考jemalloc –> 说说设计原则(分规格,就是有不同规格的内存块,以及实现了每个线程都有自己的内存) —> 列举下组件(poolArean,chunk,subpage,以及基础的分配单位page)–> 分配流程(先检查请求分配的大小是tiny还是smaller,然后检查PoolThreadCache是否够,如果够就使用自己线程私有内存,然后再进行Arena分配,最终返回一个Bytebuffer给 用户)