一、力扣
1、分发糖果
先考虑一边贪心,再考虑另一边。
class Solution {
public int candy(int[] ratings) {
int n=ratings.length;
int[] left=new int[n];
left[0]=1;
for(int i=1;i<n;i++){
if(ratings[i]>ratings[i-1]){
left[i]=left[i-1]+1;
}else{
left[i]=1;
}
}
int[] right=new int[n];
right[n-1]=1;
for(int i=n-2;i>=0;i--){
if(ratings[i]>ratings[i+1]){
right[i]=right[i+1]+1;
}else{
right[i]=1;
}
}
int res=0;
for(int i=0;i<n;i++){
res+=Math.max(left[i],right[i]);
}
return res;
}
}
2、加油站
class Solution {
public int canCompleteCircuit(int[] gas, int[] cost) {
int curSum = 0; // 当前油箱剩余油量(从当前起点出发到当前站的累计盈余)
int totalSum = 0; // 总油箱剩余油量(整个环路的累计盈余)
int index = 0; // 可能的起点索引
for (int i = 0; i < gas.length; i++) {
// 计算从当前起点到i站的油量盈余(gas[i] - cost[i])
curSum += gas[i] - cost[i];
totalSum += gas[i] - cost[i];
// 如果当前剩余油量为负,说明从当前起点无法到达下一站
if (curSum < 0) {
// 将起点更新为下一站(i+1取模实现环形)
index = (i + 1) % gas.length;
// 重置当前剩余油量,从新起点重新开始累积
curSum = 0;
}
}
// 总剩余油量小于0,说明无法绕环路一周
return totalSum < 0 ? -1 : index;
}
}
3、K 次取反后最大化的数组和
class Solution {
public int largestSumAfterKNegations(int[] nums, int k) {
Arrays.sort(nums);
int sum = 0;
// 遍历排序后的数组,有负值且还有转换次数就转正
for(int i = 0; i < nums.length; i++) {
if(nums[i] < 0 && k > 0) {
nums[i] = -1 * nums[i];
k--;
}
sum += nums[i];
}
// 再排序有三种情况 1. 转换次数已经用完 此时直接返回即可
// 2.转换次数没用完 还剩偶数次,此时没有负数了,直接返回即可
// 3.转换次数没用完 还剩偶数次,此时没有负数了,返回sum-2*最小数
Arrays.sort(nums);
return sum - (k % 2 == 0? 0 : 2 * nums[0]);
}
}
4、数位dp-数字 1 的个数
因为这个是统计每个生成的数字中1的个数,所以有第二个维度,每次dfs都是与前面怎么填无关的。
算法解析:
- 数位DP思想:将数字拆解为各个位,逐位处理并统计符合条件的情况
- 记忆化剪枝:存储中间状态避免重复计算,时间复杂度优化至O(log n)
- 状态定义:memo[i][cnt1] 表示从第i位开始,已统计cnt1个1时后续的可能情况
- 两种状态转移:
- 受限状态(isLimit=true):必须小于等于原数字对应位,后续状态继承限制
- 自由状态(isLimit=false):可以取0-9,后续状态不受限 关键点:只有自由状态的结果可以被记忆化存储,受限状态的结果具有唯一性无法复用
class Solution {
public int countDigitOne(int n) {
// 将数字转换为字符数组便于逐位处理
char[] s = Integer.toString(n).toCharArray();
int m = s.length;
// 记忆化数组:memo[i][cnt1] 表示从第i位开始,已统计cnt1个1时后续的1的总数
int[][] memo = new int[m][m];
for (int[] row : memo) {
Arrays.fill(row, -1); // -1 表示未计算状态
}
// 启动DFS:从最高位开始,初始统计0个1,处于受限状态
return dfs(0, 0, true, s, memo);
}
private int dfs(int i, int cnt1, boolean isLimit, char[] s, int[][] memo) {
// 递归终止:处理完所有位数时返回累计的1的个数
if (i == s.length) {
return cnt1;
}
// 记忆化检查:非限制状态下且已计算过的状态直接返回
if (!isLimit && memo[i][cnt1] >= 0) {
return memo[i][cnt1];
}
int res = 0;
// 确定当前位的上界:受限于原数字时取当前位值,否则取9
int up = isLimit ? s[i] - '0' : 9;
// 枚举当前位可能的所有数字(0~up)
for (int d = 0; d <= up; d++) {
// 递归处理下一位:
// i+1:移动到下一位
// cnt1 + (d==1 ? 1:0):累计当前位的1的数量
// isLimit && d==up:只有当前位达到上限时,下一位才继续受限
res += dfs(i + 1, cnt1 + (d == 1 ? 1 : 0), isLimit && d == up, s, memo);
}
// 仅当不受限时记录结果,因为受限状态的结果无法被复用
if (!isLimit) {
memo[i][cnt1] = res;
}
return res;
}
}
1. 数位dp模版
class Solution {
public long numberOfPowerfulInt(long start, long finish, int limit, String s) {
String low = Long.toString(start);
String high = Long.toString(finish);
int n = high.length();
low = "0".repeat(n - low.length()) + low; // 补前导零,和 high 对齐
long[] memo = new long[n];
Arrays.fill(memo, -1);
return dfs(0, true, true, low.toCharArray(), high.toCharArray(), limit, s.toCharArray(), memo);
}
private long dfs(int i, boolean limitLow, boolean limitHigh, char[] low, char[] high, int limit, char[] s, long[] memo) {
if (i == high.length) {
return 1;
}
if (!limitLow && !limitHigh && memo[i] != -1) {
return memo[i]; // 之前计算过
}
// 第 i 个数位可以从 lo 枚举到 hi
// 如果对数位还有其它约束,应当只在下面的 for 循环做限制,不应修改 lo 或 hi
int lo = limitLow ? low[i] - '0' : 0;
int hi = limitHigh ? high[i] - '0' : 9;
long res = 0;
if (i < high.length - s.length) { // 枚举这个数位填什么
for (int d = lo; d <= Math.min(hi, limit); d++) {
res += dfs(i + 1, limitLow && d == lo, limitHigh && d == hi, low, high, limit, s, memo);
}
} else { // 这个数位只能填 s[i-diff]
int x = s[i - (high.length - s.length)] - '0';
if (lo <= x && x <= hi) { // 题目保证 x <= limit,无需判断
res = dfs(i + 1, limitLow && x == lo, limitHigh && x == hi, low, high, limit, s, memo);
}
}
if (!limitLow && !limitHigh) {
memo[i] = res; // 记忆化 (i,false,false)
}
return res;
}
}
2. 前导零
class Solution {
public int countSpecialNumbers(int n) {
char[] s = Integer.toString(n).toCharArray();
int[][] memo = new int[s.length][1 << 10];
for (int[] row : memo) {
Arrays.fill(row, -1); // -1 表示没有计算过
}
return dfs(0, 0, true, false, s, memo);
}
private int dfs(int i, int mask, boolean isLimit, boolean isNum, char[] s, int[][] memo) {
if (i == s.length) {
return isNum ? 1 : 0; // isNum 为 true 表示得到了一个合法数字
}
if (!isLimit && isNum && memo[i][mask] != -1) {
return memo[i][mask]; // 之前计算过
}
int res = 0;
if (!isNum) { // 可以跳过当前数位
res = dfs(i + 1, mask, false, false, s, memo);
}
// 如果前面填的数字都和 n 的一样,那么这一位至多填数字 s[i](否则就超过 n 啦)
int up = isLimit ? s[i] - '0' : 9;
// 枚举要填入的数字 d
// 如果前面没有填数字,则必须从 1 开始(因为不能有前导零)
for (int d = isNum ? 0 : 1; d <= up; d++) {
if ((mask >> d & 1) == 0) { // d 不在 mask 中,说明之前没有填过 d
res += dfs(i + 1, mask | (1 << d), isLimit && d == up, true, s, memo);
}
}
if (!isLimit && isNum) {
memo[i][mask] = res; // 记忆化
}
return res;
}
}
5、灌溉花园的最少水龙头数目
class Solution {
public int minTaps(int n, int[] ranges) {
int[] rightMost = new int[n + 1];
for (int i = 0; i <= n; i++) {
int r = ranges[i];
if (i > r) rightMost[i - r] = i + r; // 由于 i 在不断变大,对于 i-r 来说,i+r 必然是它目前的最大值
else rightMost[0] = Math.max(rightMost[0], i + r);
}
int ans = 0;
int curRight = 0; // 已建造的桥的右端点
int nextRight = 0; // 下一座桥的右端点的最大值
for (int i = 0; i < n; i++) { // 如果走到 n-1 时没有返回 -1,那么必然可以到达 n
nextRight = Math.max(nextRight, rightMost[i]);
if (i == curRight) { // 到达已建造的桥的右端点
if (i == nextRight) return -1; // 无论怎么造桥,都无法从 i 到 i+1
curRight = nextRight; // 造一座桥
ans++;
}
}
return ans;
}
}
6、回文链表
class Solution {
public boolean isPalindrome(ListNode head) {
int n=0;
for(ListNode temp=head;temp!=null;temp=temp.next) n++;
ListNode mid=find(head);
if(n%2==1) mid=mid.next;
ListNode sec=reverse(mid);
while(sec!=null){
if(sec.val!=head.val){
return false;
}
sec=sec.next;
head=head.next;
}
return true;
}
public ListNode find(ListNode head){
ListNode fast=head;
ListNode slow=head;
ListNode pre=head;
while(fast!=null&&fast.next!=null){
fast=fast.next.next;
pre=slow;
slow=slow.next;
}
pre.next=null;
return slow;
}
public ListNode reverse(ListNode head){
ListNode pre=null;
while(head!=null){
ListNode nexthead=head.next;
head.next=pre;
pre=head;
head=nexthead;
}
return pre;
}
}
7、寻找峰值
class Solution {
public int findPeakElement(int[] nums) {
int n = nums.length;
// 初始化左右指针。由于峰值至少需要比较一个邻居,右边界设为n-2(最后一个元素无需右邻居)
int left = 0, right = n - 2;
while (left <= right) {
int mid = (left + right) / 2; // 计算中间位置
// 比较中间元素与其右侧元素
if (nums[mid] < nums[mid + 1]) {
// 右侧元素更大,说明峰值可能在[mid+1, right]区间
left = mid + 1;
} else {
// 中间元素不小于右侧,峰值可能在[left, mid-1]区间或mid本身
right = mid - 1;
}
}
// 循环结束时,left指向潜在峰值。由于每次移动都保证向更高方向搜索,最终left即为峰值位置
return left;
}
}
8、路径总和 II
不带回溯dfs:
class Solution {
private List<List<Integer>> ans = new ArrayList<>();
public List<List<Integer>> pathSum(TreeNode root, int targetSum) {
List<Integer> path = new ArrayList<>();
dfs(root, targetSum, path);
return ans;
}
// targetSum和path是上面的节点传递下来的信息,ans是全局的
private void dfs(TreeNode root, int targetSum, List<Integer> path) {
if (root == null) {
return;
}
targetSum -= root.val;
path.add(root.val);
if (root.left == root.right) { // 当前节点是叶子节点
if (targetSum == 0)
ans.add(path);
return;
}
// 这题好坑啊,这里递归的话要写成new ArrayList<>(path),
// 不然path是pathSum中传入的全局变量了
dfs(root.left, targetSum, new ArrayList<>(path));
dfs(root.right, targetSum, new ArrayList<>(path));
}
}
带回溯dfs:
class Solution {
private List<List<Integer>> ans = new ArrayList<>();
public List<List<Integer>> pathSum(TreeNode root, int targetSum) {
List<Integer> path = new ArrayList<>();
dfs(root, targetSum, path);
return ans;
}
// targetSum和path是上面的节点传递下来的信息,ans是全局的
private void dfs(TreeNode root, int targetSum, List<Integer> path) {
if (root == null) {
return;
}
targetSum -= root.val;
path.add(root.val);
if (root.left == root.right) { // 当前节点是叶子节点
if (targetSum == 0)
ans.add(new ArrayList<>(path)); // 这里要new一个新的保存path的对象!!!
// return; 回溯的话这里就不能return了,return会导致remove不了
}
dfs(root.left, targetSum, path);
dfs(root.right, targetSum, path);
// 恢复现场
path.remove(path.size() - 1);
}
}
9、岛屿的最大面积
class Solution {
int[][] direct={{1,0},{-1,0},{0,1},{0,-1}};
int m,n;
int[][] grid;
public int maxAreaOfIsland(int[][] grid) {
int res=0;
this.grid=grid;
m=grid.length;
n=grid[0].length;
for(int i=0;i<m;i++){
for(int j=0;j<n;j++){
if(grid[i][j]==1){
res=Math.max(res,dfs(i,j)) ;
}
}
}
return res;
}
public int dfs(int x,int y){
grid[x][y]=0;
int res=1;
for(var dir:direct){
int nx=x+dir[0];
int ny=y+dir[1];
if(nx>=0&&nx<m&&ny>=0&&ny<n&&grid[nx][ny]==1){
res+=dfs(nx,ny);
}
}
return res;
}
}
二、项目流程
1. 抽奖活动参与流程
1.1 创建抽奖单
用户参与抽奖活动时,首先需要创建抽奖单。这个过程由 IRaffleActivityPartakeService 接口中的 createOrder 方法实现:
/**
* 创建抽奖单;用户参与抽奖活动,扣减活动账户库存,产生抽奖单。如存在未被使用的抽奖单则直接返回已存在的抽奖单。
*
* @param userId 用户ID
* @param activityId 活动ID
* @return 用户抽奖订单实体对象
*/
UserRaffleOrderEntity createOrder(String userId, Long activityId);
这个过程包括:
- 检查用户是否有未使用的抽奖单,如有则直接返回
- 扣减活动账户库存(总次数、月次数、日次数)
- 生成新的抽奖单
1.2 抽奖单状态管理
抽奖单有三种状态:
create:创建状态,表示抽奖单已创建但未使用used:已使用状态,表示抽奖单已被使用进行抽奖cancel:已作废状态
2. 抽奖执行流程
2.1 查询未使用的抽奖单
系统通过 IUserRaffleOrderDao 接口的 queryNoUsedRaffleOrder 方法查询用户未使用的抽奖单:
@DBRouter
UserRaffleOrder queryNoUsedRaffleOrder(UserRaffleOrder userRaffleOrderReq);
对应的SQL查询:
<select id="queryNoUsedRaffleOrder" parameterType="cn.bugstack.infrastructure.persistent.po.UserRaffleOrder" resultMap="dataMap">
select user_id, activity_id, activity_name, strategy_id, order_id, order_time, order_state
from user_raffle_order
where user_id = #{userId} and activity_id = #{activityId} and order_state = 'create'
</select>
2.2 执行抽奖策略
系统根据活动ID获取对应的策略ID,然后执行抽奖策略。抽奖策略可能包含规则树的处理,通过 IRuleTreeDao、IRuleTreeNodeDao 和 IRuleTreeNodeLineDao 等接口实现。
2.2.1 获取抽奖策略
通过活动ID获取对应的策略ID:
Long queryStrategyIdByActivityId(Long activityId);
然后根据策略ID获取完整的策略配置:
Strategy queryStrategyByStrategyId(Long strategyId);
2.2.2 规则树处理
如果抽奖策略涉及规则树,系统会进行规则树的处理:
- 获取规则树信息:
RuleTree queryRuleTreeByTreeId(String treeId);
- 获取规则树节点:
List<RuleTreeNode> queryRuleTreeNodeListByTreeId(String treeId);
- 获取规则树节点连线:
List<RuleTreeNodeLine> queryRuleTreeNodeLineListByTreeId(String treeId);
- 根据规则树进行决策,确定最终的抽奖策略或奖品池。
2.3 使用事务记录中奖结果
抽奖完成后,系统会:
- 更新抽奖单状态为已使用
- 记录用户中奖信息
- 创建任务发送消息通知
这个过程在 AwardRepository 类的 saveUserAwardRecord 方法中实现:
- 事务执行 :通过 execute() 方法执行事务,接受一个 lambda 表达式或回调函数
- 事务状态 :回调函数接收一个 TransactionStatus 对象,可以通过它控制事务
- 回滚控制 :通过 status.setRollbackOnly() 方法标记事务需要回滚
- 异常处理 :在事务执行过程中,如果发生异常,事务会自动回滚
@Override
public void saveUserAwardRecord(UserAwardRecordAggregate userAwardRecordAggregate) {
// ... existing code ...
try {
dbRouter.doRouter(userId);
transactionTemplate.execute(status -> {
try {
// 写入记录
userAwardRecordDao.insert(userAwardRecord);
// 写入任务
taskDao.insert(task);
// 更新抽奖单
int count = userRaffleOrderDao.updateUserRaffleOrderStateUsed(userRaffleOrderReq);
if (1 != count) {
status.setRollbackOnly();
log.error("写入中奖记录,用户抽奖单已使用过,不可重复抽奖 userId: {} activityId: {} awardId: {}", userId, activityId, awardId);
throw new AppException(ResponseCode.ACTIVITY_ORDER_ERROR.getCode(), ResponseCode.ACTIVITY_ORDER_ERROR.getInfo());
}
return 1;
} catch (DuplicateKeyException e) {
status.setRollbackOnly();
log.error("写入中奖记录,唯一索引冲突 userId: {} activityId: {} awardId: {}", userId, activityId, awardId, e);
throw new AppException(ResponseCode.INDEX_DUP.getCode(), e);
}
});
} finally {
dbRouter.clear();
}
try {
// 发送消息【在事务外执行,如果失败还有任务补偿】
eventPublisher.publish(task.getTopic(), task.getMessage());
// 更新数据库记录,task 任务表
taskDao.updateTaskSendMessageCompleted(task);
} catch (Exception e) {
log.error("写入中奖记录,发送MQ消息失败 userId: {} topic: {}", userId, task.getTopic());
taskDao.updateTaskSendMessageFail(task);
}
}
在 AwardRepository.java 文件中,我们可以看到系统使用了 TransactionTemplate 进行事务管理。使用事务有以下几个重要好处:
2.4 使用事务好处
1. 保证数据一致性
在抽奖系统中,一次完整的中奖记录保存涉及三个操作:
- 写入用户中奖记录( userAwardRecordDao.insert )
- 写入消息通知任务( taskDao.insert )
- 更新用户抽奖订单状态( userRaffleOrderDao.updateUserRaffleOrderStateUsed )
这三个操作必须同时成功或同时失败,否则会导致数据不一致。例如:
- 如果只写入了中奖记录但没更新抽奖单状态,用户可能会重复抽奖
- 如果只更新了抽奖单状态但没写入中奖记录,用户的中奖信息会丢失
事务确保这些操作要么全部成功,要么全部回滚,保证了数据的一致性。
2. 防止重复抽奖
代码中有这样的判断:
int count = userRaffleOrderDao.updateUserRaffleOrderStateUsed(userRaffleOrderReq);
if (1 != count) {
status.setRollbackOnly();
log.error("写入中奖记录,用户抽奖单已使用过,不可重复抽奖 userId: {} activityId: {} awardId: {}", userId, activityId, awardId);
throw new AppException(ResponseCode.ACTIVITY_ORDER_ERROR.getCode(), ResponseCode.ACTIVITY_ORDER_ERROR.getInfo());
}
通过事务机制,如果发现抽奖单已被使用(更新影响行数不为1),会立即回滚所有操作,防止重复抽奖导致的奖品多发。
3. 处理并发问题
在高并发场景下,可能会有多个请求同时尝试使用同一个抽奖单。事务配合数据库的行锁机制,可以确保只有一个请求能成功更新抽奖单状态,其他请求会因为条件不满足( order_state = 'create' )而更新失败,从而避免并发问题。
3. 数据库分库分表设计
系统使用了分库分表的设计,通过 @DBRouter 和 @DBRouterStrategy 注解实现:
@Mapper
@DBRouterStrategy(splitTable = true)
public interface IUserRaffleOrderDao {
// ... existing code ...
}
这样可以根据用户ID进行路由,提高系统的扩展性和性能。
4. 活动账户管理
系统通过 IRaffleActivityAccountDao 和 IRaffleActivityAccountDayDao 接口管理用户的抽奖次数:
- 总次数限制
- 月次数限制
- 日次数限制
当用户参与抽奖时,会扣减相应的次数配额。
5. 完整抽奖流程总结
-
用户参与活动:
- 检查活动是否有效
- 检查用户是否有抽奖资格(次数限制)
- 创建抽奖单
-
执行抽奖:
- 获取未使用的抽奖单
- 根据策略ID获取抽奖策略
- 执行抽奖算法
- 确定中奖结果
-
处理中奖结果:
- 更新抽奖单状态为已使用
- 记录用户中奖信息
- 创建消息通知任务
- 发送中奖通知
-
异常处理:
- 事务管理确保数据一致性
- 失败重试机制(通过任务表实现)
- 日志记录
三、Redis跳表
1. 跳表节点类(SkipListNode)
class SkipListNode {
int score; // 排序依据的分数
String member; // 成员数据(类似Redis的键)
SkipListNode[] forward; // 每层的下一个节点指针数组
public SkipListNode(int score, String member, int level) {
this.score = score;
this.member = member;
this.forward = new SkipListNode[level + 1]; // 层数从0开始
}
}
2. 跳表类(SkipList)
import java.util.Random;
public class SkipList {
private static final int MAX_LEVEL = 16; // 最大层数
private static final double P = 0.25; // 节点晋升概率
private int level; // 当前实际最大层数
private SkipListNode header; // 头节点
private int length; // 总节点数
private Random random; // 随机数生成器
public SkipList() {
this.level = 0;
this.header = new SkipListNode(-1, null, MAX_LEVEL);
this.length = 0;
this.random = new Random();
}
// 随机生成节点的层数
private int randomLevel() {
int lvl = 0;
while (random.nextDouble() < P && lvl < MAX_LEVEL - 1) {
lvl++;
}
return lvl;
}
// 查找节点的前驱节点数组(用于插入/删除)
private SkipListNode[] findPredecessors(int score) {
SkipListNode[] predecessors = new SkipListNode[MAX_LEVEL + 1];
SkipListNode current = header;
// 从最高层开始查找
for (int i = level; i >= 0; i--) {
while (current.forward[i] != null && current.forward[i].score < score) {
current = current.forward[i];
}
predecessors[i] = current; // 记录每层的前驱
}
return predecessors;
}
// 插入操作
public boolean insert(int score, String member) {
SkipListNode[] predecessors = findPredecessors(score);
SkipListNode newNode = new SkipListNode(score, member, randomLevel());
// 如果新节点的层数超过当前跳表的最大层数
if (newNode.forward.length - 1 > level) {
for (int i = level + 1; i <= newNode.forward.length - 1; i++) {
predecessors[i] = header;
}
level = newNode.forward.length - 1;
}
// 在每一层插入新节点
for (int i = 0; i < newNode.forward.length; i++) {
newNode.forward[i] = predecessors[i].forward[i];
predecessors[i].forward[i] = newNode;
}
length++;
return true;
}
// 删除操作
public boolean delete(int score, String member) {
SkipListNode[] predecessors = findPredecessors(score);
SkipListNode target = predecessors[0].forward[0]; // 最底层下一个节点
// 检查是否存在目标节点
if (target == null || target.score != score || !target.member.equals(member)) {
return false;
}
// 更新每一层的指针
for (int i = 0; i <= level; i++) {
if (predecessors[i].forward[i] != target) {
break; // 如果当前层的前驱节点的下一个节点不是目标节点,停止更新
}
predecessors[i].forward[i] = target.forward[i];
}
// 如果最高层没有节点了,降低跳表的层数
while (level > 0 && header.forward[level] == null) {
level--;
}
length--;
return true;
}
// 查找节点是否存在
public boolean contains(int score, String member) {
SkipListNode node = findNode(score, member);
return node != null && node.member.equals(member);
}
// 辅助方法:查找指定分数和成员的节点
private SkipListNode findNode(int score, String member) {
SkipListNode current = header;
for (int i = level; i >= 0; i--) {
while (current.forward[i] != null && current.forward[i].score < score) {
current = current.forward[i];
}
}
current = current.forward[0]; // 移动到最底层下一个节点
if (current != null && current.score == score && current.member.equals(member)) {
return current;
}
return null;
}
// 打印跳表结构(调试用)
public void printAll() {
System.out.println("跳表当前层数: " + level);
for (int i = level; i >= 0; i--) {
SkipListNode node = header.forward[i];
System.out.print("第" + i + "层: ");
while (node != null) {
System.out.printf("(score=%d, member=%s) -> ", node.score, node.member);
node = node.forward[i];
}
System.out.println("NULL");
}
}
public static void main(String[] args) {
SkipList sl = new SkipList();
sl.insert(3, "A");
sl.insert(1, "B");
sl.insert(2, "C");
sl.insert(4, "D");
System.out.println("查找 (2, C): " + sl.contains(2, "C")); // true
System.out.println("删除 (2, C): " + sl.delete(2, "C")); // true
System.out.println("查找 (2, C): " + sl.contains(2, "C")); // false
sl.printAll();
}
}
3、跳表结构回顾
跳表(Skip List)是一种多层链表结构,通过随机生成的索引层加速查找。每个节点包含多个指针(forward),指向不同层的下一个节点。头节点(header)始终位于最高层,用于遍历。跳表的每一层都是下一层的“快速通道”,底层包含所有元素并按顺序链接。
4、插入操作步骤
-
查找插入位置的前驱节点
• 目标:确定新节点在每一层中的插入位置。• 方法:从最高层开始,逐层向右遍历,直到找到最后一个值小于待插入节点的节点,记录为该层的“前驱”。
• 结果:得到一个前驱数组
predecessors,其中predecessors[i]表示第i层中最后一个小于待插入节点的节点。 -
随机生成新节点的层数
• 规则:通过概率p(如 0.25)决定层数。例如,每次有p的概率增加一层,直到达到最大层数maxLevel。• 意义:高层节点较少,平衡查找效率,避免退化为链表。
-
处理层数变化
• 如果新节点的层数newLevel大于当前跳表的最大层数currentLevel:◦ 将
currentLevel更新为newLevel。◦ 新增的前驱层(
currentLevel + 1到newLevel)的前驱设为头节点。 -
创建新节点并插入各层
• 创建节点:新节点的层数为newLevel,初始化所有层的指针为null。• 更新指针:
◦ 对每一层
i(从0到newLevel):◦ 新节点的 `forward[i]` 指向 `predecessors[i].forward[i]`(原下一节点)。 ◦ `predecessors[i].forward[i]` 指向新节点。• 更新跳表长度:总节点数加 1。
5、删除操作步骤
-
查找目标节点及其前驱
• 目标:找到待删除节点target及其每一层的前驱节点predecessors。• 方法:类似插入操作,从最高层开始遍历,最终在最底层确认是否存在目标节点(需匹配
score和member)。 -
移除目标节点
• 遍历所有层:对每一层i(从0到当前跳表的最大层数currentLevel):◦ 如果
predecessors[i].forward[i]是target,则将其指针指向target.forward[i],跳过target。• 意义:从所有层中移除
target,恢复链表连续性。 -
调整跳表层数
• 如果最高层(currentLevel)的头节点指针为null(即该层无节点):◦ 将
currentLevel减 1,直到找到存在节点的最高层。• 意义:保持跳表层数最小化,节省空间。
-
更新跳表长度:总节点数减 1。
6、关键细节
-
前驱节点的作用
• 插入和删除时,必须记录每一层的前驱节点,否则无法正确更新指针,导致链表断裂。 -
随机层数的平衡性
• 通过概率控制层数,高层节点稀疏,低层密集,保证跳表的查找效率接近O(log n)。 -
头节点的特殊处理
• 头节点始终位于最高层,简化插入和删除的边界条件处理(如第一个节点的插入)。 -
删除后的层数调整
• 若删除节点后,最高层无节点,需降低跳表层数,避免无效遍历。
7、示例流程
插入示例:插入 (score=2, member=C)
- 查找前驱:在最高层找到
B(score=1),次高层同样找到B。 - 随机生成层数(假设为 2)。
- 更新指针:将
C插入到B的下方,并在次高层链接到A。
删除示例:删除 (score=2, member=C)
- 查找确认
C存在。 - 遍历所有层,将
B的指针直接指向A和D。 - 若最高层变为空,降低跳表层数。
8、时间复杂度
• 插入和删除:O(log n),需遍历各层查找前驱。
• 查找:O(log n),从最高层快速跳跃缩小范围。