一、面试
1、讲讲消耗积分抽奖功能上,整体上的设计
消耗积分抽奖功能的核心设计围绕积分消耗、概率控制、库存管理、用户体验展开。整体流程分为四个模块:
- 用户积分验证:用户发起抽奖时,校验其积分余额是否满足最低消耗门槛(如100积分),并扣除对应积分(可设置事务保证原子性)。
- 奖品配置策略:后台配置奖品池,包含奖品ID、名称、权重(概率)、总库存、每日发放上限等字段,支持动态调整权重和库存。
- 抽奖算法实现:基于权重随机算法(如轮盘赌或二分查找优化版)确定中奖奖品,同时实时校验奖品库存是否充足。
- 库存与记录管理:中奖后扣减对应奖品库存,记录抽奖日志(用户ID、奖品ID、时间等),并异步触发库存预警(如库存低于阈值时通知运营补货)。
此外,需加入防刷机制(如IP频率限制、用户抽奖间隔冷却),并通过消息队列解耦高并发场景下的积分扣减与库存更新操作,保证系统稳定性。
2、抽奖功能上是如何实现的?涉及哪些数据库和表的操作?
抽奖功能的核心实现依赖后端逻辑与数据库事务的配合,涉及以下表设计与操作:
- 用户表(user):存储用户积分余额、抽奖记录ID等字段,抽奖时通过
UPDATE语句扣减积分并记录操作流水。 - 奖品表(prize):包含
prize_id、weight(概率权重)、total_stock(总库存)、daily_remaining(当日剩余)等字段,抽奖前需查询有效奖品列表(过滤库存为0的奖品)。 - 抽奖记录表(lottery_record):记录每次抽奖结果(用户ID、奖品ID、时间戳),用于数据统计与用户查询。
- 库存操作:抽奖命中奖品后,通过数据库事务(或分布式锁)原子性执行
UPDATE prize SET total_stock = total_stock - 1 WHERE ...,并检查库存是否耗尽,若为0则标记奖品为“已抢空”。
3、时间复杂度怎么从o(n)到o(1)的
《大营销平台系统设计实现》 - 营销服务 第3节:策略概率装配处理
抽奖时间复杂度从 O(n) 到 O(1) 的本质优化:
1. O(n) 方案
传统 O(n) 方案需在每次抽奖时遍历所有奖品,动态计算累积概率(如遍历 3 个奖品需 3 次累加判断),导致时间复杂度与奖品数量 n 线性相关其中,O(n) 的 n 是奖品总数,代表每次抽奖需线性遍历的复杂度;O(1) 的实现依赖预生成固定长度的查找表,适用于概率静态、高并发场景。
在传统的 O(n) 抽奖方案 中,每次抽奖需要遍历所有奖品计算累积概率的根本原因在于:动态概率匹配 和 缺乏预计算机制。
假设奖品概率为 [0.1, 0.02, 0.003],总概率为 0.123。
抽奖时需要生成一个 [0, 0.123) 的随机数,判断它落在哪个奖品的概率区间内:
- 奖品1:
[0, 0.1) - 奖品2:
[0.1, 0.12) - 奖品3:
[0.12, 0.123)
为了确定随机数属于哪个区间,必须遍历所有奖品并计算累积概率,直到找到第一个满足条件的区间。
代码示例:O(n) 方案的典型实现
public Award drawLottery(List<Award> awards) {
double random = Math.random() * getTotalRate(awards); // 总概率
double cumulative = 0.0;
// 遍历所有奖品,计算累积概率
for (Award award : awards) {
cumulative += award.getRate();
if (random < cumulative) {
return award;
}
}
return null;
}
2. O(1) 方案
该代码通过概率权重算法构建抽奖查找表,核心逻辑为:基于奖品最小概率值(如0.003)确定概率范围值(1000),将各奖品概率转换为对应占位数量(如0.003→3个占位),生成有序概率分布表后乱序存储。最终实现:1个随机数在[0,123)范围内生成时,按占位数量比例(100:20:3)映射到不同奖品,保证概率公平性的同时实现高效抽奖计算,并通过Redis持久化存储概率分布表。
3. 核心代码
/**
* 装配抽奖策略配置「触发的时机可以为活动审核通过后进行调用」
*
* @param strategyId 策略ID
* @return 装配结果
*/
@Override
public boolean assembleLotteryStrategy(Long strategyId) {
// 1. 查询策略配置
List<StrategyAwardEntity> strategyAwardEntities = repository.queryStrategyAwardList(strategyId);
// 2 缓存奖品库存【用于decr扣减库存使用】
for (StrategyAwardEntity strategyAward : strategyAwardEntities) {
Integer awardId = strategyAward.getAwardId();
Integer awardCount = strategyAward.getAwardCountSurplus();
cacheStrategyAwardCount(strategyId, awardId, awardCount);
}
// 3.1 默认装配配置【全量抽奖概率】
assembleLotteryStrategy(String.valueOf(strategyId), strategyAwardEntities);
// 3.2 权重策略配置 - 适用于 rule_weight 权重规则配置【4000:102,103,104,105 5000:102,103,104,105,106,107 6000:102,103,104,105,106,107,108,109】
StrategyEntity strategyEntity = repository.queryStrategyEntityByStrategyId(strategyId);
String ruleWeight = strategyEntity.getRuleWeight();
if (null == ruleWeight) return true;
StrategyRuleEntity strategyRuleEntity = repository.queryStrategyRule(strategyId, ruleWeight);
// 业务异常,策略规则中 rule_weight 权重规则已适用但未配置
if (null == strategyRuleEntity) {
throw new AppException(ResponseCode.STRATEGY_RULE_WEIGHT_IS_NULL.getCode(), ResponseCode.STRATEGY_RULE_WEIGHT_IS_NULL.getInfo());
}
// 从策略规则实体中获取规则权重值映射表(键:规则标识,值:权重值列表)
Map<String, List<Integer>> ruleWeightValueMap = strategyRuleEntity.getRuleWeightValues();
// 遍历规则权重值映射表的每个条目
for (String key : ruleWeightValueMap.keySet()) {
// 获取当前规则标识对应的权重值列表
List<Integer> ruleWeightValues = ruleWeightValueMap.get(key);
// 克隆原始策略奖励实体列表(防止修改原始数据)
ArrayList<StrategyAwardEntity> strategyAwardEntitiesClone = new ArrayList<>(strategyAwardEntities);
// 过滤出当前规则权重值列表中包含的奖励ID对应实体
strategyAwardEntitiesClone.removeIf(entity -> !ruleWeightValues.contains(entity.getAwardId()));
// 组装抽奖策略(使用规则标识+下划线+当前key作为唯一标识)
assembleLotteryStrategy(
String.valueOf(strategyId).concat(Constants.UNDERLINE).concat(key),
strategyAwardEntitiesClone
);
}
return true;
}
3.1 首先根据策略ID查询策略配置,然后缓存到redis中
<select id="queryStrategyAwardListByStrategyId" parameterType="java.lang.Long" resultMap="dataMap">
select strategy_id, award_id, award_title, award_subtitle, award_count, award_count_surplus, award_rate, rule_models, sort
from strategy_award
where strategy_id = #{strategyId}
order by sort asc
</select>
3.2 进行默认配置装配
该代码通过概率权重算法构建抽奖查找表,核心逻辑为:基于奖品最小概率值(如0.003)确定概率范围值(1000),将各奖品概率转换为对应占位数量(如0.003→3个占位),生成有序概率分布表后乱序存储。最终实现:1个随机数在[0,123)范围内生成时,按占位数量比例(100:20:3)映射到不同奖品,保证概率公平性的同时实现高效抽奖计算,并通过Redis持久化存储概率分布表。
策略概率装配代码过程:
- 查询策略配置
- 获取最小概率值
- 获取概率值总和
- 用 1 % 0.0001 获得概率范围,百分位、千分位、万分位
- 生成策略奖品概率查找表「这里指需要在list集合中,存放上对应的奖品占位即可,占位越多等于概率越高」
- 对存储的奖品进行乱序操作。避免顺序生成的随机数前面是固定的奖品。
- 生成出Map集合,key值,对应的就是后续的概率值。通过概率来获得对应的奖品ID
- 存放到 Redis
/**
* 计算公式;
* 1. 找到范围内最小的概率值,比如 0.1、0.02、0.003,需要找到的值是 0.003
* 2. 基于1找到的最小值,0.003 就可以计算出百分比、千分比的整数值。这里就是1000
* 3. 那么「概率 * 1000」分别占比100个、20个、3个,总计是123个
* 4. 后续的抽奖就用123作为随机数的范围值,生成的值100个都是0.1概率的奖品、20个是概率0.02的奖品、最后是3个是0.003的奖品。
*/
private void assembleLotteryStrategy(String key, List<StrategyAwardEntity> strategyAwardEntities) {
// 1. 获取最小概率值
BigDecimal minAwardRate = strategyAwardEntities.stream()
.map(StrategyAwardEntity::getAwardRate)
.min(BigDecimal::compareTo)
.orElse(BigDecimal.ZERO);
// 2. 循环计算找到概率范围值
BigDecimal rateRange = BigDecimal.valueOf(convert(minAwardRate.doubleValue()));
// 3. 生成策略奖品概率查找表「这里指需要在list集合中,存放上对应的奖品占位即可,占位越多等于概率越高」
List<Integer> strategyAwardSearchRateTables = new ArrayList<>(rateRange.intValue());
for (StrategyAwardEntity strategyAward : strategyAwardEntities) {
Integer awardId = strategyAward.getAwardId();
BigDecimal awardRate = strategyAward.getAwardRate();
// 计算出每个概率值需要存放到查找表的数量,循环填充
for (int i = 0; i < rateRange.multiply(awardRate).intValue(); i++) {
strategyAwardSearchRateTables.add(awardId);
}
}
// 4. 对存储的奖品进行乱序操作
Collections.shuffle(strategyAwardSearchRateTables);
// 5. 生成出Map集合,key值,对应的就是后续的概率值。通过概率来获得对应的奖品ID
Map<Integer, Integer> shuffleStrategyAwardSearchRateTable = new LinkedHashMap<>();
for (int i = 0; i < strategyAwardSearchRateTables.size(); i++) {
shuffleStrategyAwardSearchRateTable.put(i, strategyAwardSearchRateTables.get(i));
}
// 6. 存放到 Redis
repository.storeStrategyAwardSearchRateTable(key, shuffleStrategyAwardSearchRateTable.size(), shuffleStrategyAwardSearchRateTable);
}
3.3 进行权重配置装配
如果用户配置有权重抽奖,则进行权重的装配,与默认装配相同。
- 遍历规则权重值映射表的每个条目
- 获取当前规则标识对应的权重值列表
- 克隆原始策略奖励实体列表(防止修改原始数据)
- 过滤出当前规则权重值列表中包含的奖励ID对应实体
- 组装抽奖策略(使用规则标识+下划线+当前key作为唯一标识)
4. 装配后的map是什么?
最终存入 Redis 的 Map 结构中,Key 是索引(0 到 size-1),Value 是奖品 ID。
-
Map 的键值对结构:
最终存入 Redis 的 Map 中,Key 是整数索引(从 0 开始递增),Value 是对应的奖品 ID。这种设计将概率分布转化为线性索引空间,每个索引位置通过填充高概率奖品的 ID 副本(占位)来体现权重,例如高概率奖品会占据更多索引位置,从而在随机访问时提高被选中的概率。 -
乱序的必要性:
生成查找表后,通过Collections.shuffle()打乱奖品 ID 的顺序,目的是防止攻击者通过固定顺序预测抽奖结果。乱序后的索引与奖品 ID 的映射关系更随机,确保每次生成的查找表具备不可预测性,增强抽奖公平性。 -
索引随机访问的实现机制:
抽奖时通过生成一个随机数(范围0~size-1)直接访问 Map 的 Key,即可快速定位奖品 ID。这种基于索引的随机访问方式将概率分布问题转化为线性空间的均匀随机采样,既简化了计算逻辑,又保证了加权结果的准确性。
4、如何处理奖品库存不足的情况(如随机到已抽完的奖品)?
装配规则树时就装配上兜底节点。先判断是否成功扣减,获得到了奖品,如果没有,交给兜底处理。
// 如果库存不足,则直接返回放行
log.warn("规则过滤-库存扣减-告警,库存不足。userId:{} strategyId:{} awardId:{}", userId, strategyId, awardId);
return DefaultTreeFactory.TreeActionEntity.builder()
.ruleLogicCheckType(RuleLogicCheckTypeVO.ALLOW)
.build();
二、力扣
1、二叉树代码
1. 结构代码
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;
}
}
2. 递归遍历
// 前序遍历·递归·LC144_二叉树的前序遍历
class Solution {
public List<Integer> preorderTraversal(TreeNode root) {
List<Integer> result = new ArrayList<Integer>();
preorder(root, result);
return result;
}
public void preorder(TreeNode root, List<Integer> result) {
if (root == null) {
return;
}
result.add(root.val);
preorder(root.left, result);
preorder(root.right, result);
}
}
// 中序遍历·递归·LC94_二叉树的中序遍历
class Solution {
public List<Integer> inorderTraversal(TreeNode root) {
List<Integer> res = new ArrayList<>();
inorder(root, res);
return res;
}
void inorder(TreeNode root, List<Integer> list) {
if (root == null) {
return;
}
inorder(root.left, list);
list.add(root.val); // 注意这一句
inorder(root.right, list);
}
}
// 后序遍历·递归·LC145_二叉树的后序遍历
class Solution {
public List<Integer> postorderTraversal(TreeNode root) {
List<Integer> res = new ArrayList<>();
postorder(root, res);
return res;
}
void postorder(TreeNode root, List<Integer> list) {
if (root == null) {
return;
}
postorder(root.left, list);
postorder(root.right, list);
list.add(root.val); // 注意这一句
}
}
3、二叉树迭代遍历
// 前序遍历顺序:中-左-右,入栈顺序:中-右-左
class Solution {
public List<Integer> preorderTraversal(TreeNode root) {
List<Integer> result = new ArrayList<>();
if (root == null){
return result;
}
Stack<TreeNode> stack = new Stack<>();
stack.push(root);
while (!stack.isEmpty()){
TreeNode node = stack.pop();
result.add(node.val);
if (node.right != null){
stack.push(node.right);
}
if (node.left != null){
stack.push(node.left);
}
}
return result;
}
}
// 中序遍历顺序: 左-中-右 入栈顺序: 左-右
class Solution {
public List<Integer> inorderTraversal(TreeNode root) {
List<Integer> result = new ArrayList<>();
if (root == null){
return result;
}
Stack<TreeNode> stack = new Stack<>();
TreeNode cur = root;
while (cur != null || !stack.isEmpty()){
if (cur != null){
stack.push(cur);
cur = cur.left;
}else{
cur = stack.pop();
result.add(cur.val);
cur = cur.right;
}
}
return result;
}
}
// 后序遍历顺序 左-右-中 入栈顺序:中-左-右 出栈顺序:中-右-左, 最后翻转结果
class Solution {
public List<Integer> postorderTraversal(TreeNode root) {
List<Integer> result = new ArrayList<>();
if (root == null){
return result;
}
Stack<TreeNode> stack = new Stack<>();
stack.push(root);
while (!stack.isEmpty()){
TreeNode node = stack.pop();
result.add(node.val);
if (node.left != null){
stack.push(node.left);
}
if (node.right != null){
stack.push(node.right);
}
}
Collections.reverse(result);
return result;
}
}
4、统一迭代法
4.1 前序
class Solution {
public List<Integer> preorderTraversal(TreeNode root) {
//(前序遍历-中左右,入栈顺序右左中)
List<Integer> result = new LinkedList<>();
Stack<TreeNode> st = new Stack<>();
if (root != null) st.push(root);
while (!st.empty()) {
TreeNode node = st.peek();
if (node != null) {
st.pop(); // 将该节点弹出,避免重复操作,下面再将右左中节点添加到栈中
if (node.right!=null) st.push(node.right); // 添加右节点(空节点不入栈)
if (node.left!=null) st.push(node.left); // 添加左节点(空节点不入栈)
st.push(node); // 添加中节点
st.push(null); // 中节点访问过,但是还没有处理,加入空节点做为标记。
} else { // 只有遇到空节点的时候,才将下一个节点放进结果集
st.pop(); // 将空节点弹出
node = st.peek(); // 重新取出栈中元素
st.pop();
result.add(node.val); // 加入到结果集
}
}
return result;
}
}
4.2 中序
class Solution {
public List<Integer> inorderTraversal(TreeNode root) {
List<Integer> result = new LinkedList<>();
Stack<TreeNode> st = new Stack<>();
if (root != null) st.push(root);
while (!st.empty()) {
TreeNode node = st.peek();
if (node != null) {
st.pop(); // 将该节点弹出,避免重复操作,下面再将右中左节点添加到栈中(中序遍历-左中右,入栈顺序右中左)
if (node.right!=null) st.push(node.right); // 添加右节点(空节点不入栈)
st.push(node); // 添加中节点
st.push(null); // 中节点访问过,但是还没有处理,加入空节点做为标记。
if (node.left!=null) st.push(node.left); // 添加左节点(空节点不入栈)
} else { // 只有遇到空节点的时候,才将下一个节点放进结果集
st.pop(); // 将空节点弹出
node = st.peek(); // 重新取出栈中元素
st.pop();
result.add(node.val); // 加入到结果集
}
}
return result;
}
}
4.3 后序
class Solution {
public List<Integer> postorderTraversal(TreeNode root) {
List<Integer> result = new LinkedList<>();
Stack<TreeNode> st = new Stack<>();
if (root != null) st.push(root);
while (!st.empty()) {
TreeNode node = st.peek();
if (node != null) {
st.pop(); // 将该节点弹出,避免重复操作,下面再将中右左节点添加到栈中(后序遍历-左右中,入栈顺序中右左)
st.push(node); // 添加中节点
st.push(null); // 中节点访问过,但是还没有处理,加入空节点做为标记。
if (node.right!=null) st.push(node.right); // 添加右节点(空节点不入栈)
if (node.left!=null) st.push(node.left); // 添加左节点(空节点不入栈)
} else { // 只有遇到空节点的时候,才将下一个节点放进结果集
st.pop(); // 将空节点弹出
node = st.peek(); // 重新取出栈中元素
st.pop();
result.add(node.val); // 加入到结果集
}
}
return result;
}
}
5、有效的括号
class Solution {
public boolean isValid(String s) {
ArrayDeque<Character> stack=new ArrayDeque<>();
for(int i=0;i<s.length();i++){
char temp=s.charAt(i);
if(temp=='('||temp=='{'||temp=='['){
stack.push(temp);
}else{
if(stack.isEmpty()||!check(stack.peek(),temp)){
return false;
}
stack.pop();
}
}
return stack.isEmpty()?true:false;
}
public boolean check(Character a,Character b){
if((a=='('&&b==')')||(a=='{'&&b=='}')||(a=='['&&b==']')){
return true;
}
return false;
}
}