项目结构搭建:从系统结构图到多模块 Maven 项目设计

本文记录了我在餐饮管理系统项目中,从系统结构图设计到多模块 Maven 项目搭建的完整思考过程。


一、从产品原型到系统结构图

在项目初期,我根据产品原型首先尝试绘制系统结构图,用于梳理系统的整体模块划分。

初版结构图的问题

最初绘制的结构图中,各个模块是平级关系,主要关注功能划分,而忽略了:

  • 模块之间的 层级关系
  • 模块之间的 依赖方向
  • 不同模块在工程中的 关注重点

在这一阶段,我逐渐意识到:
系统结构图不只是“模块列表”,而是模块间关系的表达。


结构图的逐步演进

在不断调整后,我对结构图提出了更明确的目标:

  • 能体现 调用层级
  • 能反映 依赖方向
  • 能提示 开发过程中的关键关注点(如高并发、事务敏感、高频读等)

在这一原则下,结构图经历了多次迭代,最终形成了第三版系统结构图,用于指导后续的工程搭建。


二、从系统结构图到项目模块规划

基于最终的系统结构图,我开始尝试将设计落地为实际的项目结构。

初始拆分思路

最初的出发点是:

  • 结构清晰
  • 各层职责独立

因此,我按照经典的三层架构,将项目拆分为三个 Maven Module:

  • controller
  • service
  • dao

从“概念理解”的角度看,这样的拆分似乎是合理的。


三、对照教学案例后的关键反思

在对照教学案例(如 sky-take-out)后,我发现一个非常明显的差异:

教学案例的模块划分方式

案例项目并 没有 将 controller / service / dao 拆分为独立 Maven Module,而是采用了:

  • pojo:实体类与数据模型模块
  • common:通用常量、工具类、公共定义
  • server:业务模块(包含 controller / service / mapper)

并且:所有模块由一个 parent pom 统一管理


我的拆分方式存在的问题

对比之后,我逐渐意识到自己最初的设计存在以下隐患:

  1. controller / service / dao 强耦合、同步演进
  2. 业务变化频繁,模块边界不稳定
  3. 缺少统一的实体与通用模块
    • 实体类、常量、工具类被迫分散在不同模块
    • 不可避免地出现模块间交叉 import
  4. 结构复杂,但并未带来工程收益

最终结果很可能是:模块越拆越乱。


四、什么该拆 Module,什么不该拆

Maven Module ≠ 逻辑分层

在这一阶段,我对 Maven Module 的角色有了更清晰的认识:

Maven Module 的拆分依据不是 MVC 分层,而是工程属性。

更合理的判断标准是:

  • 稳定性:是否长期稳定、不易变化
  • 复用性:是否会被多个模块依赖
  • 变化频率:是否频繁随业务调整

合理的拆分原则

  • 稳定、通用、可复用的内容
    → 适合拆为 Maven Module(如 common、pojo)
  • 强耦合、变化频繁的业务逻辑
    → 更适合作为 package 层次存在(controller / service / mapper)

这一认知也解释了为什么教学案例会选择将 MVC 分层整合在 server 模块中。


五、项目搭建:从“能跑”到“规范”

在统一思路后,我按照案例的工程结构重新搭建了项目,形成了如下多模块 Maven 项目:

1
2
3
4
5
backend-catering-management-system
├── common
├── pojo
├── server
└── pom.xml

父工程的角色

父工程(parent pom)主要职责是:

  • 统一管理依赖版本
  • 统一插件配置
  • 管理子模块(Aggregator)

在这一过程中,我遇到了一个典型的 Maven 报错:

1
Aggregator projects require 'pom' as packaging

问题与结论

通过该问题明确了一个关键规则:

  • 聚合父工程的 packaging 必须是 pom
  • 父工程不参与业务代码编译
  • 只有子模块才是 jar

这一细节也进一步加深了我对 Maven 多模块项目结构的理解。


六、阶段性总结

通过从系统结构图到项目搭建的完整过程,我对后端工程结构有了更清晰的认识:

  • 项目结构不能自由发挥,一切为了方便开发为主
  • 复杂的结构并不一定带来更好的工程效果
  • 知道“为什么不这么拆”,比“我能拆得多细”更重要

学习资料与完整代码

已整理并上传至 GitHub 仓库

Spring Boot 配置与测试实战复盘

本文基于一次完整的学习与踩坑过程,系统复盘 配置绑定、宽松绑定、属性校验、测试配置覆盖、Web 测试 等关键机制,重点回答一个问题:
Spring Boot 为什么要这样设计?我在工程中应该如何正确使用?

一、Spring Boot 热部署的本质:不是“热更新”,而是 ClassLoader 重启

在传统 Java Web 项目中,热部署通常由外置 Web 容器完成;
而 Spring Boot 采用 内嵌容器,服务器本身运行在 Spring 容器中,因此热部署的实现方式完全不同。

devtools 的核心原理

Spring Boot 的热部署依赖 spring-boot-devtools

1
2
3
4
5
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<optional>true</optional>
</dependency>

其核心并不是 JVM 层面的“代码热替换”,而是 类加载器分层重启

  • base ClassLoader:加载第三方依赖(jar 包),只加载一次
  • restart ClassLoader:加载业务代码,修改后可重新加载

热部署的本质是:重启 restart ClassLoader,而不是整个 JVM

为什么线上环境必须关闭热部署?

  • ClassLoader 重启可能带来状态不一致
  • 与线上稳定性目标冲突
  • devtools 本身只服务于开发阶段
  • 额外的开销,线上改不了源码,所以也不会启动热部署

因此,热部署是开发工具


二、@ConfigurationProperties:配置绑定的正确方式

为什么不推荐大量使用 @Value?

@Value 属于“点对点注入”,在配置复杂时会带来问题:

  • 类型不安全
  • 分散、不可维护
  • 无法集中校验

相比之下,@ConfigurationProperties 提供了 结构化配置绑定

1
2
3
4
5
6
7
8
9
10
11
servers:
ip-address: 192.168.0.1
port: 2345
timeout: 3h

@ConfigurationProperties(prefix = "servers")
public class ServerConfig {
private String ipAddress;
private int port;
private Duration timeout;
}

三、宽松绑定 ≠ 配置名可以随便写(第一个踩坑点)

我最初的误解

Spring Boot 配置支持宽松绑定,忽略大小写、中划线、下划线
那是不是 dataSourcedata_sourcedata-source 都可以?

实际踩坑现象

1
2
dataSource:
url: jdbc:mysql://...

启动时报错:

1
2
Configuration property name 'dataSource' is not valid
Canonical names should be kebab-case

3.3 正确理解(非常重要)

  • 宽松绑定发生在:配置项 → Java 字段
  • 但配置 key 本身必须是合法的 canonical 名称

正确写法:

1
2
datasource:
url: ...

或官方标准写法:

1
2
3
spring:
datasource:
url: ...

宽松绑定解决的是“如何映射”,不是“命名是否合法”。


四、@Validated 是一把“双刃剑”(第二个踩坑点)

1
2
3
4
5
6
7
8
@ConfigurationProperties(prefix = "servers")
@Validated
public class ServerConfig {

@Min(1)
@Max(1234)
private int port;
}

在生产环境中的价值

  • 配置非法 → 容器启动失败
  • fail-fast,避免线上事故
  • 非常符合工程安全性要求

在测试环境中被“反杀”

测试中尝试覆盖配置:

1
2
3
@SpringBootTest(properties = {
"servers.port=8888"
})

结果直接启动失败:

1
ConstraintViolationException

原因:
8888 超出了 @Max(1234)

正确的工程姿势

  • 测试只覆盖 servers.port(Web 端口)
  • 业务配置仍使用合法值
1
2
3
@SpringBootTest(properties = {
"servers.port=8888"
})

测试不是绕过校验,而是在合法范围内模拟不同环境。


六、Web 层测试:为什么我选择 MockMvc

1
2
3
@SpringBootTest
@AutoConfigureMockMvc
class WebTest { }

MockMvc 的优势

  • 不需要真实端口
  • 不依赖网络
  • 执行速度快
  • 适合 CI / 自动化测试

JSON 断言的工程选择

  • 接口稳定:content().json()
  • 接口可能演进:jsonPath()

七、我从这次 Spring Boot 学习中总结的经验

  1. 配置名是否合法,比是否能绑定更早发生
  2. 宽松绑定只解决映射问题,不解决命名问题
  3. @Validated 是 fail-fast,不是调试工具
  4. 测试配置覆盖必须遵守业务约束
  5. Web 测试优先 MockMvc,而不是启动真实端口

学习资料与完整代码

已整理并上传至 GitHub 仓库

从回溯到记忆化搜索到递推:动态规划巩固练习

今天的练习并不是学习新的 DP 模板,而是围绕两个最经典的模型:打家劫舍 与 爬楼梯,去做“变形题”的识别与迁移

通过这一组题目的训练,我逐渐体会到:

动态规划的关键不在于记公式,而在于识别“本质模型”,并主动把陌生题目转化为熟悉结构。

基础题目:198. 打家劫舍

打家劫舍模型变形

  • 740. 删除并获得点数

爬楼梯模型变形(方案数)

  • 2466. 统计构造好字符串的方案数
  • 377. 组合总和 Ⅳ
  • 2266. 统计打字方案数

经典二维 DP

  • 64. 最小路径和

核心模型回顾

在进入具体题目之前,先明确两个核心模型的“本质约束”。

1. 打家劫舍模型的本质

  • 每个元素都有「选 / 不选」两种状态
  • 一旦选择某个元素,就会限制相邻元素不能被选择
  • 状态转移通常是:
1
dp[i] = max(dp[i - 1], dp[i - 2] + value[i])

重点不在“房子”,而在相邻约束


2. 爬楼梯模型的本质

  • 本质是一个排列问题
  • 顺序不同 = 不同方案
  • 给定一个目标值 target
  • 每一步可以选择若干“步长”,问总方案数

只要是:

  • 「目标值固定」
  • 「每一步可以选择若干选项」
  • 「顺序敏感」

都可以往爬楼梯模型上靠


结合具体题目分析

740. 删除并获得点数 —— 打家劫舍的值域改造

题意简述
选择一个数 nums[i],可以获得 nums[i] 的点数,但会删除所有值为 nums[i] - 1nums[i] + 1 的元素。

思路转化

这道题的难点在于:

  • 原数组中,相同数字可能出现多次
  • 删除的是“值相邻”,而不是“位置相邻”

关键一步:构造打家劫舍的条件

将数组转为值域数组

  • sums[x]:表示值为 x 的所有元素之和
  • 例如:
1
2
nums = [2,2,3,3,3,4]
sums = [0,0,4,9,4]

此时问题变成:

sums 数组中,选择若干不相邻的元素,使得总和最大

完全等价于打家劫舍


2466. 统计构造好字符串的方案数 —— 爬楼梯模型

这道题表面是字符串问题,但本质非常清晰:

  • 当前字符串长度 = 已爬的台阶数
  • 每一步可以:
    • 增加 zero 个字符
    • 或增加 one 个字符
  • 问:长度在 [low, high] 区间内的方案总数

这是标准的爬楼梯模型

  • dp[i]:构造长度为 i 的方案数
  • 每次从 i - zeroi - one 转移而来

377. 组合总和 Ⅳ —— 爬楼梯 + 顺序敏感

这道题非常具有代表性:

  • 给定 nums
  • 目标和 target
  • 不同顺序算不同方案

本质理解

可以这样理解:

target 阶楼梯
每次可以爬 nums[i]
问一共有多少种爬法

顺序不同 → 不同路径


2266. 统计打字方案数 —— 分组 + 爬楼梯

这道题本身规则较复杂,但从 DP 角度可以拆解为:

  1. 按连续相同数字分组
  2. 每一组内部:
    • 是一个「爬楼梯问题」
    • 不同按键允许的最大步长不同
  3. 最终答案 = 各组方案数相乘

这是一个非常典型的:

局部 DP + 全局组合


64. 最小路径和 —— 经典二维 DP

这是标准的网格 DP:

  • dp[i][j] 表示到 (i, j) 的最小路径和
  • 当前状态只依赖:
    • 上方 (i - 1, j)
    • 左方 (i, j - 1)
1
dp[i][j] = min(dp[i - 1][j], dp[i][j - 1]) + grid[i][j]

没有变形,但非常适合作为 DP 基础模板反复巩固


心得与方法论总结

1. 打家劫舍 ≠ 偷房子

打家劫舍的真正核心是:

选择一个元素,会导致“相邻状态失效”

  • 740 题中,相邻的是「值」
  • 所以我们主动构造值域数组,人为制造相邻关系

没有条件,就创造条件


2. 爬楼梯 = 顺序敏感的方案计数

爬楼梯模型特别适合解决:

  • 方案数问题
  • 和 / 长度固定的问题
  • 顺序不同算不同的情况

例如:

  • 字符串构造
  • 数字组合
  • 步数累加

把「目标值」当成台阶数,把「选择」当成一步能走的距离


总结

通过今天这一组题目的训练,我对动态规划有了一个更重要的认知转变:

DP 的关键不是记住状态转移方程,而是识别题目的“原型模型”。

  • 看到「相邻不能同时选」 → 想打家劫舍
  • 看到「目标固定 + 多种选择 + 顺序敏感」 → 想爬楼梯

相关代码

本文涉及的所有代码与笔记,均已同步至我的 GitHub 算法仓库,作为 Java 后端校招过程中的学习记录。

动态规划:从回溯到记忆化搜索,再到递推

核心思想:当前状态的最优解,来源于之前状态的最优解

基础题目:198. 打家劫舍

扩展题目

  • 爬楼梯
  • 使用最小花费爬楼梯
  • 爬楼梯 II
  • 打家劫舍 II

一、什么是动态规划?

很多人第一次接触动态规划,都会被「状态转移方程」劝退。但实际上,动态规划并不是一种“新算法”,而是回溯的一种系统性优化

动态规划 = 回溯 + 去重 + 自底向上

理解动态规划,最自然的一条路径是:

回溯 → 记忆化搜索 → 递推(DP)

下面我们通过「打家劫舍」这个经典问题,完整走一遍这条路径。


二、从回溯开始:暴力 DFS 的本质

198. 打家劫舍 为例:

  • 每一间房子:选 or 不选
  • 不能选相邻的房子

我们从“最后一个房子”开始思考,定义:

dfs(i):考虑前 i 个房子,能偷到的最大金额

那么对于第 i 个房子:

  • 不偷:最大金额 = dfs(i - 1)
  • 偷:最大金额 = dfs(i - 2) + nums[i]

得到递归公式:

1
dfs(i) = max(dfs(i - 1), dfs(i - 2) + nums[i])

但问题也随之而来:大量重复计算

1

可以看到:

  • dfs(2) 被计算了多次
  • dfs(1) 被计算了更多次

这正是 动态规划要解决的核心问题:重复子问题


三、记忆化搜索:给回溯加“缓存”

回溯的问题不在于“递归”,而在于 重复递归

解决方法也很直接:

算过的结果,存下来,下次直接用

这就是 记忆化搜索(Memoization)

Java 示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private int dfs(int i, int[] nums, int[] cache) {
if (i < 0) {
return 0;
}
if (cache[i] != -1) {
// 之前计算过,直接返回
return cache[i];
}
// 之前没算过,将当前结果缓存
cache[i] = Math.max(
dfs(i - 1, nums, cache),
dfs(i - 2, nums, cache) + nums[i]
);
return cache[i];
}

此时:

  • 时间复杂度:从指数级 ➜ O(n)
  • 但仍然有:
    • 递归栈空间
    • cache 数组空间

接下来就是最后一步优化空间复杂度:递推


四、从记忆化搜索到递推(真正的 DP)

观察记忆化搜索中的递归关系:

  • dfs(2) 依赖 dfs(1)dfs(0)
  • dfs(3) 依赖 dfs(2)dfs(1)
  • ……

这说明:

状态之间的依赖顺序是确定的

既然如此,我们完全可以:

  • 不再“递”
  • 只保留“归”
  • 从小到大计算每一个状态

递推(DP)的三要素

从记忆化搜索到递推,本质上完成了三件事:

  1. 状态数组(dp)dp[i] 记录 dfs(i) 的结果
  2. 循环代替递归:从 i = 0 推到 n
  3. 初始化代替递归边界dp[0]dp[1] 对应原来的递归边界

为什么叫“递推”?

因为当前状态是 由之前状态推导出来的

在打家劫舍中:

  • 偷到第 i 间房子的最大金额
    不是只由第 i 间房子决定的
  • 而是由:
    • i-1 间房子的最优解
    • i-2 间房子的最优解
      共同决定

这正是动态规划中最核心的结构:

最优子结构


五、空间优化:从 O(n) 到 O(1)

在打家劫舍中:

1
dp[i] 只依赖 dp[i-1] 和 dp[i-2]

因此:

  • 不需要完整 dp 数组
  • 只需要保存「前两个状态」

空间复杂度可进一步优化为 O(1)


六、结合具体题目总结 DP 模型

70. 爬楼梯

  • 状态定义dp[i] = 爬到第 i 阶的方法数
  • 递推公式
1
dp[i] = dp[i - 1] + dp[i - 2]
  • 初始化
    • dp[0] = 1
    • dp[1] = 1
  • 答案dp[n]

本质:斐波那契数列


746. 使用最小花费爬楼梯

  • 状态定义dp[i] = 到达第 i 阶的最小花费
  • 递推公式
1
2
dp[i] = min(dp[i - 1] + cost[i - 1],
dp[i - 2] + cost[i - 2])
  • 初始化
    • dp[0] = 0
    • dp[1] = 0
  • 答案dp[n]

特点: 不是计数,而是最小值优化


3693. 爬楼梯 II

  • 一次可以跳 1 / 2 / 3
  • 递推公式
1
dp[i] = dp[i - 1] + dp[i - 2] + dp[i - 3]

本质变化只有:

当前状态依赖的“历史状态数量”变多了,计算 cost 的方式不同了


213. 打家劫舍 II(环形)

核心难点:

首尾不能同时选

经典处理方式:

  • 情况一:不偷第 0 间 ➜ 偷 [1 … n-1]
  • 情况二:不偷第 n-1 间 ➜ 偷 [0 … n-2]
  • 对两种情况分别做 198 打家劫舍
  • 取最大值

环形 DP → 拆成两个线性 DP


七、心得与方法论总结

动态规划到底“动”在哪?

  • 状态是变化的
  • 当前状态来自之前状态
  • 本质是 dfs 中的“归”

递推,其实就是:

省略 dfs 的“递”,只保留“归”


想不出递推公式怎么办?

一个非常实用的技巧:

回退一步,用回溯 + 记忆化搜索先写出来

  • 回溯天然符合“选 or 不选”
  • 递归公式写出来后
  • 直接“翻译”为 dp 即可

什么时候应该想到动态规划?

当题目满足以下特征之一时,高度警惕 DP

  • 当前结果依赖之前的结果
  • 有“最优”“最多”“最少”“方案数”等关键词
  • 存在大量重复子问题
  • 能清晰定义「状态」

一句话总结:

只要当前状态是在之前状态的基础上演化而来,就可以尝试动态规划


结语

动态规划并不是死记模板,而是一种 从回溯中抽象出的系统性思维方式
真正掌握 DP 的标志,不是会写状态转移方程,而是:

能从回溯自然地推导出递推

相关代码

本文涉及的所有代码与笔记,均已同步至我的 GitHub 算法仓库,作为 Java 后端校招过程中的学习记录。

回溯总结篇

在系统完成回溯相关题型后,本篇博客旨在对 回溯(Backtracking)这一类算法思想进行统一分析与方法论总结,帮助建立稳定的解题模型。


一、什么是回溯?

回溯不是一种具体算法,而是一种搜索思想。

从直观角度理解,回溯可以看作是「悔棋」:

  • 当前选择了一条路往下走
  • 发现这条路走不通,或者已经走完
  • 退回到上一个状态
  • 改选另一条路继续尝试

回溯与 DFS 的关系

回溯通常通过 递归 + 深度优先遍历(DFS) 实现。

  • DFS 负责:一路向下探索
  • 回溯负责:返回时撤销选择,恢复到上一个状态

如果把搜索过程看作一棵树:

  • 向子节点走 → 递归深入
  • 回到父节点 → 回溯
  • 访问兄弟节点 → 新的选择

什么是「增量构造答案」?

增量构造答案
答案不是一开始就完整出现的,而是在搜索过程中一步步被“拼”出来的

以集合 (1, 2, 3) 的子集问题为例:

  • 我们不会一开始就知道一个完整子集
  • 而是:
    1. 先决定:要不要 1
    2. 再决定:要不要 2
    3. 再决定:要不要 3
  • 每一次选择,都会在当前路径上 “增加一点信息”

因此:

  • 当前路径 path 始终是一个 “未完成的答案”
  • 只有当满足终止条件时,它才成为一个 完整答案

这也是剪枝能成立的根本原因

如果在“构造过程中”已经不满足条件,就没必要继续往下走。


二、回溯三问(解题核心视角)

在写回溯代码前,几乎所有题目都可以先回答这三个问题。

电话号码的字母组合 为例(用 path 记录路径):


问题一:当前在做什么?

当前这一层,我要决定什么?

  • 决定 path[i] 填什么字母

问题二:子问题是什么?

在当前选择之后,还剩下什么问题没解决?

  • 构造字符串中 索引 ≥ i 的部分

问题三:递归如何推进?

当前层和下一层的关系是什么?

  • 当前决定第 i
  • 递归进入第 i + 1

只要这三点清楚,回溯的递归结构自然就出来了


三、恢复现场(回溯的关键)

在 DFS + 回溯过程中,我们通常会经历:

  1. 做出一个选择(加入 path
  2. 递归深入
  3. 返回时 撤销这个选择

如果不撤销,就会导致:

  • 当前路径“污染”后续分支
  • 结果错误或重复

常见的两种恢复现场方式

回撤(push + pop)

1
2
3
path.add(x);
dfs();
path.remove(path.size() - 1);

适合 path 长度不固定的情况。


覆盖(固定长度数组)

1
2
path[index] = x;
dfs(index + 1);

适合:

  • 全排列
  • 固定长度字符串构造

四、回溯的三种典型类型

回溯题目并不是杂乱无章的,大多数都可以归入以下三类。

以数组 [1, 2, 3] 为例:


子集型回溯(选 / 不选)

核心问题

每个元素,选还是不选?

两种视角

  • 从输入视角
    • 对每个元素做「选 / 不选」决策
    • 搜索树是 严格二叉树
    • 答案通常在叶子节点产生
  • 从答案构造视角
    • 枚举第 i 个答案位置选哪个元素
    • 搜索树是 多叉树
    • 答案可以在每个节点产生

组合型回溯(子集 + 约束)

组合型回溯本质上是:

对子集型回溯增加“合法性约束 + 剪枝”

常见约束包括:

  • k 个数
  • 和等于 target

为什么可以剪枝?

因为答案是增量构造的

  • 如果当前路径已经:
    • 选多了
    • 和超了
    • 剩余元素不可能满足条件
  • 那么继续向下递归 一定不可能得到合法答案

可以提前返回,剪掉整棵子树。


排列型回溯(顺序不同即不同)

排列型回溯的核心特征:

  • 顺序敏感
  • [1,2][2,1] 是不同答案

与前两类的本质区别

  • 子集 / 组合:

    之前选过的元素,后面不能再选

  • 排列:

    只要当前路径没用过,就可以选

因此需要:

  • 一个 used / flag 数组
  • 表示当前路径中哪些元素已经使用过

五、统一的回溯伪代码模板

1
2
3
4
5
6
7
8
9
10
11
12
void backtracking(参数) {
if (终止条件) {
记录答案;
return;
}

for (选择:当前层可选的所有选项) {
做选择;
backtracking(下一层参数);
撤销选择; // 恢复现场
}
}

六、心得总结

  • 回溯问题 不一定是“选或不选”

  • 但一定是 “做选择 → 走一条路 → 回退 → 换一条路”

  • 本质是:

    在状态空间中系统性地枚举所有可能解

一旦你能:

  • 明确「当前层在决定什么」
  • 明确「路径代表什么」
  • 明确「什么时候可以停、什么时候该剪」

那么回溯题目就不再是“凭感觉写代码”,而是一个高度可复用的方法论问题

排列型回溯

排列型回溯的本质是「每一层都可以从所有未使用的元素中重新选择」,顺序不同即视为不同答案,通常通过 used / flag 数组 来记录当前路径中已使用的元素。


一、什么是排列型回溯?

在回溯问题中,排列型问题有一个非常鲜明的特征:

  • 元素相同,但顺序不同,算作不同答案
  • 例如:
    • [1, 2][2, 1]两个不同的解

这与我们之前做过的两类问题形成了清晰对比:

类型 是否关心顺序 是否允许重复选
子集型 ❌ 不关心 每个元素只选 / 不选
组合型 ❌ 不关心 对于选或不选有约束条件
排列型 ✅ 关心 每一轮可选任意当前轮次未使用元素

二、排列型回溯的核心思想

从「枚举答案的角度」来看,排列型回溯有两个关键点:

每一层都在“选位置”,而不是“选元素范围”

  • 第 0 位选谁?
  • 第 1 位选谁?
  • 第 2 位选谁?

每一层都可以从 当前轮所有尚未使用的元素中选择


必须显式记录「当前路径中已使用的元素」

因此,排列型回溯 一定需要 一个 used / flag 数组:

1
2
used[i] = true  → nums[i] 已经在当前排列中使用过
used[i] = false → 当前轮次仍可选择 nums[i]

三、基础题目分析

46. 全排列(Permutations)

问题特征

  • 目标:生成数组的所有排列
  • 终止条件:当前路径长度 == nums.length
  • 每一层:从所有 used[i] == false 的元素中选一个

核心实现思路

  1. 使用 path 记录当前排列
  2. 使用 used[] 标记哪些元素已被选
  3. path.size() == nums.length 时,记录答案
  4. 回溯时恢复现场(used[i] = false

关键点总结

  • 排列的深度 = nums.length
  • 叶子节点数量 = n!
  • used[] 的作用是:保证每个元素在同一条路径中只出现一次

51. N 皇后(N-Queens)

这是一个非常经典的“受限排列问题”


问题拆解

  • 每一行只能放一个皇后
  • 每一列只能放一个皇后
  • 皇后不能在同一条对角线上

建模方式(非常关键)

用一个一维数组 queens 表示棋盘状态:

1
queens[row] = col

含义是:

  • row
  • col
  • 放置了一个皇后

为什么这是一个排列问题?

  • 行天然不重复(递归层数保证)
  • 列不能重复 → queens 本质是一个 列索引的全排列
  • 对角线限制 → 给这个全排列 增加合法性约束

对角线判断条件

若两个皇后在 (r1, c1)(r2, c2)

1
|r1 - r2| == |c1 - c2| → 在同一对角线

本质总结

N 皇后 = 带约束条件的全排列问题


四、扩展题目

357. 统计各位数字都不同的数字个数

这道题虽然形式不同,但本质仍然是:

  • 在每一位上选数字
  • 同一个数字不能重复使用
  • 位数不同,形成不同答案

本质是 多层排列 + 剪枝计数,而不是生成具体排列。


五、排列型回溯的时间复杂度

排列问题的时间复杂度通常非常直观:

  • 等于叶子节点数量

  • 对于 n 个元素的全排列:

    1
    时间复杂度 = O(n!) // 画树分析节点数量得到

这是排列问题不可避免的代价,因此:

  • 剪枝尤为重要
  • 约束条件越多,搜索空间越小

六、心得体会与方法论总结

排列型回溯的固定模板

  1. 路径长度固定(通常等于元素个数)
  2. 每一层从 所有未使用元素中选择
  3. 使用 used[] / flag[] 记录使用状态
  4. 回溯时一定要 恢复现场

与前两类回溯的根本区别

是否允许在下一层重新选择之前没选过的元素

  • 子集 / 组合:「之前轮次选过,就不能再选」
  • 排列:「只要当前路径没用过,就可以选」

相关代码

本文涉及的所有代码与笔记,均已同步至我的 GitHub 算法仓库,作为 Java 后端校招过程中的学习记录。

Spring Boot + SSMP 基础实战与问题复盘

本文基于一次完整的 Spring Boot 基础项目实践,总结了从实体类 → 数据层 → 业务层 → 表现层 → 前后端联调的开发流程,并重点记录了在整合 MyBatis-Plus、JUnit、分页插件等过程中遇到的典型问题及解决方案,作为后续复盘与查错参考。

项目采用 单体架构(非前后端分离),以熟悉 Spring Boot + MyBatis-Plus 的基础开发模式为目标。

一、实体类开发(Entity)

1. 数据库准备

  • 创建业务表(如 tbl_book
  • 初始化测试数据

2. 实体类创建

  • 根据表结构创建实体类
  • 使用 Lombok 简化样板代码(getter / setter / toString 等)
1
2
3
4
5
6
7
@Data
public class Book {
private Long id;
private String type;
private String name;
private String description;
}

二、数据层开发(CRUD)

技术选型

  • ORM 框架:MyBatis-Plus
  • 数据源:Druid
  • 测试框架:JUnit

核心步骤

  1. 引入 MyBatis-Plus Starter
  2. 配置数据库连接信息
  3. 配置 MP 相关属性
    • 表名前缀(table-prefix
    • 主键策略(id-type
    • SQL 日志(log-impl
  4. 使用 BaseMapper<T> 快速完成 CRUD
  5. 使用 @Mapper@MapperScan 交给 Spring 管理
  6. 编写 Mapper 测试类验证功能

三、问题复盘 ①:测试类能运行,测试方法却报 NoSuchMethodError

现象

  • 直接运行测试类 ✔
  • 单独运行测试方法 ❌ 报错:NoSuchMethodError

原因分析

  • Classpath 中存在多个 JUnit 版本
  • Spring Boot 默认引入的测试依赖与本地环境冲突

解决过程

  • 尝试在 pom.xml 中强制覆盖 JUnit 版本 → ❌ 无效
  • 最终解决方案:降低 Spring Boot 版本

Spring Boot 4.x 降级到 Spring Boot 3.x 后问题消失

结论:
测试相关问题优先排查:Spring Boot 版本 × Starter 版本 × IDE 运行方式


四、问题复盘 ②:Spring Boot 3 + MyBatis-Plus 启动时报 Mapper Bean 异常

报错信息

1
Invalid bean definition with name 'xxxMapper'

排查过程

  • @Mapper
  • @MapperScan
  • 包路径无误 ✔

根本原因

Spring Boot 3 与 MyBatis-Plus Starter 版本不兼容

正确依赖方式

1
2
3
4
5
6
7
8
9
10
11
<!-- Spring Boot 2.x -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-starter</artifactId>
</dependency>

<!-- Spring Boot 3.x -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
</dependency>

结论:
Spring Boot 3 必须使用 boot3 专用的 MyBatis-Plus Starter


五、问题复盘 ③:配置了 IdType.AUTO,却生成了“雪花 ID”

现象

  • 实体类主键配置为 IdType.AUTO
  • 插入后 ID 却像雪花算法生成

真正原因

并非 AUTO 失效,而是:

  • 之前使用过 雪花 ID 策略
  • 删除数据后,数据库的 AUTO_INCREMENT 未重置
  • 产生了“脏 ID ”

解决方式

1
ALTER TABLE tbl_book AUTO_INCREMENT = 1;

结论:
主键策略问题,一定要同时检查:代码配置 + 数据库状态


六、数据层开发(分页功能)

MyBatis-Plus 分页 API

1
2
3
4
5
6
7
8
9
10
11
@Test
void testGetPage(){
IPage<Book> page = new Page<>(2, 5);
bookMapper.selectPage(page, null);

System.out.println(page.getCurrent());
System.out.println(page.getSize());
System.out.println(page.getTotal());
System.out.println(page.getPages());
System.out.println(page.getRecords());
}

分页参数说明

  • 当前页码
  • 每页条数

必须配置分页拦截器(由于分页是方言,为了提高扩展性,通过拦截器的方式实现)

1
2
3
4
5
6
7
8
9
10
@Configuration
public class MPConfig {

@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor(){
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor());
return interceptor;
}
}

七、问题复盘 ④:分页不生效

原因

  • MyBatis-Plus 将分页能力拆分为插件
  • 未引入解析 SQL 的依赖

解决方案

1
2
3
4
5
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-jsqlparser</artifactId>
<version>3.5.15</version>
</dependency>

八、数据层开发(条件查询)

普通条件构造(存在风险)

1
2
3
QueryWrapper<Book> qw = new QueryWrapper<>();
qw.like("name", "Spring");
bookMapper.selectList(qw);

❌ 问题:字段名是字符串,编译期无法检查


Lambda 条件构造(推荐)

1
2
3
LambdaQueryWrapper<Book> lqw = new LambdaQueryWrapper<>();
lqw.like(name != null, Book::getName, name);
bookMapper.selectList(lqw);

✅ 优点:

  • 编译期安全
  • 支持条件开关
  • 重构友好

九、业务层开发(Service)

业务层职责:

组织业务逻辑,对数据层进行封装调用

开发步骤:

  1. 定义 Service 接口
  2. 编写 ServiceImpl
  3. 注入 Mapper
  4. 编写测试验证逻辑正确性

十、表现层开发(Controller)

核心工作

  • 编写 REST Controller
  • 接收参数
  • 调用业务层
  • 返回统一结果

参数接收注意点

  • JSON 实体:@RequestBody
  • 路径变量:@PathVariable

使用 ApiFox 进行接口测试与调试。


十一、统一返回结果设计

为保证前后端交互一致性:

  • 封装统一响应对象
  • 包含:
    • code
    • data
    • msg
  • 同时考虑异常场景

十二、前后端联调

  • 将前端静态资源拷贝到:

    1
    resources/static
  • Spring Boot 自动托管静态资源

  • 实现简单的前后端一体化访问


总结

通过这次 Spring Boot 基础实战,我完成了:

  • 一条完整的 SSMP 开发链路
  • 多个 真实问题的排查与解决
  • 版本兼容性、插件机制、主键策略 的深入理解

学习资料与完整代码

已整理并上传至 GitHub 仓库

组合型回溯 + 剪枝:从子集枚举到条件收敛

组合型回溯 + 剪枝

一句话总结
组合型回溯本质仍是子集型回溯,但通过“组合约束 + 单调性剪枝”,将指数级搜索空间大幅收缩,只遍历“有可能成为答案”的分支。


一、什么是组合型回溯?

在回溯问题中,我们通常会遇到两类经典模型:

  • 子集型回溯
    枚举所有可能的子集(选 / 不选),不关心长度或数值约束
  • 组合型回溯
    在子集枚举的基础上,只保留满足条件的组合

常见的组合约束包括:

  • 固定长度(如选 k 个数)
  • 固定和(如和为 target)
  • 结构合法性(如括号匹配、IP 段合法)

因此,组合型回溯 = 子集型回溯 + 条件约束 + 剪枝


二、组合型回溯的核心思想

组合型回溯的关键并不在「怎么枚举」,而在于:

当前状态已经不可能构成合法解时,要尽早停止搜索

这正是剪枝的价值所在。

常见剪枝维度

  1. 数量剪枝
    • 剩余可选元素 < 还需要选的数量 → 直接返回
  2. 数值剪枝(选择元素为正)
    • 当前和已经超过 target
    • 即使选最大值,也无法达到 target
  3. 结构剪枝
    • 不满足合法结构(如右括号多于左括号)

这些剪枝往往都依赖于一个核心性质:

单调性
当前状态不满足条件,向下扩展只会更不满足。


三、典型题目拆解与剪枝思路

77. 组合(Combinations)

目标:从 [1…n] 中选 k 个数

关键剪枝

  • 数量剪枝
    • 剩余数字个数 < 还需要选择的数量 → 直接返回
  • 提前返回
    • 当已选数量 == k,记录答案,不再向下搜索

本质理解

这是一个固定长度的组合问题,搜索树深度是确定的,剪枝点非常清晰。


216. 组合总和 III

目标:从 [1…9] 中选 k 个数,和为 n

关键剪枝

  1. leftTarget < 0
    • 所有数均为正数,后续必然无解
  2. leftTarget > 剩余可选数字的最大可能和
    • 即使全选最大值,也无法凑够
  3. 剩余数字个数 < 还需要选择的数量

关键

  • 同时利用了「一大一小」两种剪枝方向
  • 体现出数值约束与数量约束的对称性

22. 括号生成

目标:生成 n 对合法括号

状态定义

  • left:已使用左括号数量
  • right:已使用右括号数量

剪枝规则

  • left > n → 非法
  • right > left → 非法

本质理解

这道题可以视为一种带结构约束的组合回溯

  • “选” → 加左括号
  • “不选” → 加右括号(但有条件)

本质不是排列,而是合法结构的组合生成


39. 组合总和(可重复选择)

目标:元素可重复选,和为 target

关键剪枝

  1. leftTarget < 0 → 直接返回

  2. leftTarget == 0 → 记录答案并返回

  3. 排序 + 剪枝

    若当前元素 > leftTarget,后续元素更大,可直接 break

可重复选择的表达

  • 使用 dfs(i) 表示当前元素可再次选择
  • 通过起始索引控制是否允许重复

93. 复原 IP 地址

目标:将字符串分割为 4 段合法 IP

强剪枝条件

  1. 字符数量剪枝

    剩余字符数 ∉ [剩余段数, 剩余段数 * 3]

  2. 数值剪枝

    当前段 > 255

  3. 前导零剪枝

    段以 0 开头但长度 > 1

补充思路

  • 由于段数固定为 4
  • 该题也可以用 三重循环 实现
  • 但回溯解法在结构上更统一、可复用性更强

四、方法论总结:如何写好组合型回溯?

推荐解题步骤

  1. 先不剪枝,写出正确解

  2. 明确以下要素:

    状态变量(路径、索引、剩余目标)

    终止条件

  3. 回看搜索树,问自己:

    当前状态已经不可能成功了吗?

  4. 将「不可能成功」的情况提前 return

一个重要认知

剪枝为了利用题目的单调性

  • 当前不满足 → 未来一定不满足
  • 才是可以安全剪枝的前提

五、个人心得体会

  • 组合型回溯并不是新的模型
    它只是对子集型回溯的“条件收敛

  • 实战中:

    先保证正确性、再逐步加剪枝,思路更清晰

  • 多数高质量剪枝,都来源于:

    • 数量边界
    • 数值上下界
    • 结构合法性

当你能一眼看出「当前状态是否还有希望」,回溯题就不再是暴力搜索,而是受控的状态枚举

相关代码

本文涉及的所有代码与笔记,均已同步至我的 GitHub 算法仓库,作为 Java 后端校招过程中的学习记录。

Spring Boot 基础入门与整合实践

本文记录了我在学习 Spring Boot 基础与整合 MyBatis、Druid 过程中的核心知识点,以及一次完整、真实的排错过程。
重点不在于“配置怎么写”,而在于理解 Spring Boot 自动配置的工作机制


一、Spring Boot 简介

Spring Boot 是由 Pivotal 团队提供的快速开发框架,目标是:

  • 简化 Spring 应用的初始搭建
  • 减少繁琐的 XML / Java 配置
  • 通过 自动配置(Auto Configuration)+ Starter 机制 提升开发效率

二、Spring Boot 入门方式

2.1 创建方式说明

  • 使用 IDEA / 官网 / 阿里云脚手架:需要联网
  • 手动创建 Maven 项目:
    • 继承 spring-boot-starter-parent
    • 手动编写启动类
    • 不需要联网(前提是本地仓库已有依赖)

三、parentstarter 的作用区分

3.1 parent:统一依赖版本管理

spring-boot-starter-parent 的作用是:

  • 统一管理常用依赖的版本
  • 避免版本冲突
  • 简化 pom 文件

注意
parent 只“管理版本”,并不会实际引入任何依赖。


3.2 starter:功能级依赖集合

starter 的作用是:

  • 一次性引入某个技术栈所需的依赖
  • 通过依赖传递减少配置成本

例如:

  • spring-boot-starter-web
  • spring-boot-starter-jdbc
  • mybatis-spring-boot-starter

parent + starter 解决的是 “配置复杂度”问题


四、Spring Boot 启动类

1
2
3
4
5
6
@SpringBootApplication
public class Springboot01QuickstartApplication {
public static void main(String[] args) {
SpringApplication.run(Springboot01QuickstartApplication.class, args);
}
}

启动类的作用

  • 标识这是一个 Spring Boot 应用
  • 启动 Spring 容器
  • 扫描当前包及其子包下的 Bean
  • 返回值是 ApplicationContext

五、内嵌 Web 容器机制

在引入 spring-boot-starter-web 后:

  • 默认内嵌 Tomcat
  • Spring 会创建并管理一个 Tomcat 对象
  • 启动应用时自动启动 Web 服务器

Spring Boot 也支持 Jetty、Undertow 等内嵌容器。


六、基础配置文件

6.1 配置文件类型与优先级

Spring Boot 支持:

  • application.properties
  • application.yml
  • application.yaml

优先级(从高到低):

1
properties > yml > yaml

相同配置高优先级覆盖,未冲突配置全部生效。


6.2 为什么推荐 YAML

  • 层级清晰
  • 可读性更好
  • 更适合复杂配置

YAML 核心规则

  • 大小写敏感
  • 缩进表示层级(只能用空格)
  • key: value 中冒号后必须有空格
  • # 表示注释

6.3 YAML 数据绑定方式

方式一:@Value

1
2
@Value("${enterprise.name}")
private String name;

方式二:Environment

1
environment.getProperty("enterprise.name");

方式三(推荐):@ConfigurationProperties

1
2
3
4
5
6
7
8
9
@Component
@ConfigurationProperties(prefix = "enterprise")
@Data
public class Enterprise {
private String name;
private int age;
private String tel;
private String[] hobby;
}

本质:
Spring Boot 内部大量使用这种方式完成第三方组件的自动配置


七、整合第三方技术

7.1 Spring Boot 整合 JUnit

  • 自动引入测试 starter
  • 使用 @SpringBootTest
  • 测试类不在启动类包下时需指定 classes

7.2 Spring Boot 整合 MyBatis

MyBatis 主要涉及两类配置:

  1. 全局配置(数据源)
  2. 映射配置(XML / 注解)

基本步骤:

  • 引入 MyBatis Starter
  • 配置数据源
  • 定义 Mapper 接口
  • 注入 Mapper 测试

7.3 Spring Boot 整合 Druid

  • 引入 Druid Starter
  • 配置 Druid 专属配置项
  • 验证 DataSource 类型

八、整合过程中的问题与排错总结(重点)

问题一:UserDao 无法注入

现象

1
No qualifying bean of type 'tingfeng.mapper.UserDao' available

原因

  • Mapper 接口未被 Spring 扫描
  • @Mapper 只对 MyBatis 生效

解决方式

1
2
3
@SpringBootApplication
@MapperScan("tingfeng.mapper")
public class Quickstart03Application { }

关键认知

@Mapper 是 MyBatis 的,@MapperScan 才是 Spring 的


问题二:Mapper 能扫描,但报 sqlSessionFactory 缺失

现象

1
Property 'sqlSessionFactory' or 'sqlSessionTemplate' are required

定位结论

  • Mapper 已创建
  • SqlSessionFactory 未生成
  • SqlSessionFactory 依赖 DataSource

实际解决方式

mybatis-spring-boot-starter 从 3.0.4 升级至 4.0.2

根因分析(重要)

新版本调整了:

  • Mapper 初始化时机
  • SqlSessionFactory 校验逻辑

避免在 DataSource 尚未完全初始化时进行强校验,从而绕开了该异常路径。

⚠️ 注意:
这并不代表 DataSource 问题“消失”,而是校验时机发生了变化


九、总结与反思

这次学习和排错过程中,我最大的收获不是“配置记住了多少”,而是:

  • 理解了 Starter 才是自动配置的触发条件
  • 明白了 异常信息可能并不是根因
  • 学会从 DataSource → SqlSessionFactory → Mapper 反向排查
  • 认识到 版本升级可能改变行为,而非修复根因

十、一句话总结

Spring Boot 整合 MyBatis 与数据源时,
问题往往不在 Mapper 本身,
而在自动配置链路是否完整、以及校验时机是否合理。


学习资料与完整代码

已整理并上传至 GitHub 仓库

子集型回溯方法论得应用(2026-01-08)

一、输入角度的子集型回溯

子集型回溯是一类通过枚举每个元素“选 or 不选”来构造答案集合的回溯问题。
其核心思想是:

对输入集合中的每一个元素,都做一次“是否加入当前解”的选择,通过递归枚举所有可能的子集。

由于每个元素只有两种状态(选 / 不选),因此这类问题天然覆盖所有可能情况,不会重、不漏


二、子集型回溯的统一模板

子集型回溯的 DFS 过程通常包含三个核心要素:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void dfs(i, 输入集合,当前状态):
// n 为输入集合长度
if i == n:
// 说明当前轮枚举完了输入集合
更新答案
return

// 不选第 i 个元素
dfs(i + 1, 输入集合,当前状态)

// 选第 i 个元素(有条件)
if check(i, 当前状态):
选择 i
dfs(i + 1, 输入集合,新状态)
撤销选择

其中:

  • i:当前处理到第 i 个元素
  • 当前状态:路径(已选元素 / 剩余资源 / 当前得分等)
  • check:是否允许“选”的条件判断(条件回溯的关键

三、扩展题目统一拆解

下面 6 道题,本质上都可以统一为「子集型回溯 + 条件判断」


LCP51. 烹饪料理

建模方式

  • 枚举第 i 道料理:做 or 不做
  • 选的条件:材料足够

状态维护

  • 剩余材料数组

典型的「资源约束型子集回溯


2397. 被列覆盖的最多行数

建模方式

  • 枚举第 i 列:选 or 不选
  • 选的条件:已选列数 ≤ numSelect

关键剪枝

  • 如果 剩余列数 ≤ 剩余可选数必须全选

子集型回溯 + 强剪枝


1239. 串联字符串的最大长度

建模方式

  • 枚举每个字符串:选 or 不选

选的条件

  1. 字符串内部不能有重复字符
  2. 与当前已选字符串字符集不能冲突

状态设计

  • 使用 boolean[26] 维护字符占用情况

典型的「集合冲突约束型回溯


2212. 射箭比赛中的最大得分

建模方式

  • 枚举每个得分区间是否争夺

选的条件

  • 剩余箭数 ≥ alice[i] + 1

状态维护

  • 剩余箭数
  • 当前得分

本质是 资源分配 + 子集枚举


2698. 求一个整数的惩罚数

建模方式

  • 的字符串进行切割
  • 每一刀:切 or 不切

选的条件

  • 当前分割出的数字之和 ≤ i

本质是 字符串分割型子集回溯


93. 复原 IP 地址(从答案角度分析)

建模方式

  • 字符串分割,一共分割为 4 段
  • 枚举第 i 段从哪里分割

隐含条件(关键)

  1. 必须切成 4 段
  2. 每段 ∈ [0,255]
  3. 不能有前导 0

分割约束的字符串回溯


五、子集型回溯的优化与剪枝

条件剪枝(最常见)

  • 选之前判断是否合法
  • 不合法直接跳过

提前终止(上界剪枝)

  • 剩余元素即使全选,也不可能更优
  • 直接 return

强制选择

  • 剩余元素数 ≤ 剩余配额
  • 不再分支,直接全选

六、心得体会与方法论总结

  • 子集型回溯本质是一种暴力但系统的枚举方法
  • 通过“选 / 不选”两条分支,可以完整覆盖解空间
  • 时间复杂度通常为 O(2^n),但剪枝决定了实际性能
  • 带条件的子集回溯,核心是:
    • 把“是否能选”的逻辑抽离成 check
    • 让 DFS 结构保持干净、统一
  • 很多看似不同的题(资源分配、字符串切割、组合选择),在建模层面是同一类问题

相关代码

本文涉及的所有代码与笔记,均已同步至我的 GitHub 算法仓库,作为 Java 后端校招过程中的学习记录。