博客记录-day150-力扣(数位dp)+项目流程+Redis跳表插入与删除

138 阅读18分钟

一、力扣

1、分发糖果

135. 分发糖果

image.png

先考虑一边贪心,再考虑另一边。

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、加油站

134. 加油站

image.png

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 次取反后最大化的数组和

1005. K 次取反后最大化的数组和

image.png

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 的个数

233. 数字 1 的个数

image.png

image.png

因为这个是统计每个生成的数字中1的个数,所以有第二个维度,每次dfs都是与前面怎么填无关的。

算法解析:

  1. 数位DP思想:将数字拆解为各个位,逐位处理并统计符合条件的情况
  2. 记忆化剪枝:存储中间状态避免重复计算,时间复杂度优化至O(log n)
  3. 状态定义:memo[i][cnt1] 表示从第i位开始,已统计cnt1个1时后续的可能情况
  4. 两种状态转移:
    • 受限状态(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模版

2999. 统计强大整数的数目

image.png

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. 前导零

2376. 统计特殊整数

image.png

image.png

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、灌溉花园的最少水龙头数目

1326. 灌溉花园的最少水龙头数目

image.png

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、回文链表

234. 回文链表

image.png

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、寻找峰值

162. 寻找峰值

image.png

image.png

image.png

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

113. 路径总和 II

image.png

不带回溯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、岛屿的最大面积

695. 岛屿的最大面积

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,然后执行抽奖策略。抽奖策略可能包含规则树的处理,通过 IRuleTreeDaoIRuleTreeNodeDaoIRuleTreeNodeLineDao 等接口实现。

2.2.1 获取抽奖策略

通过活动ID获取对应的策略ID:

Long queryStrategyIdByActivityId(Long activityId);

然后根据策略ID获取完整的策略配置:

Strategy queryStrategyByStrategyId(Long strategyId);
2.2.2 规则树处理

如果抽奖策略涉及规则树,系统会进行规则树的处理:

  1. 获取规则树信息:
RuleTree queryRuleTreeByTreeId(String treeId);

  1. 获取规则树节点:
List<RuleTreeNode> queryRuleTreeNodeListByTreeId(String treeId);

  1. 获取规则树节点连线:
List<RuleTreeNodeLine> queryRuleTreeNodeLineListByTreeId(String treeId);

  1. 根据规则树进行决策,确定最终的抽奖策略或奖品池。

2.3 使用事务记录中奖结果

抽奖完成后,系统会:

  1. 更新抽奖单状态为已使用
  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. 活动账户管理

系统通过 IRaffleActivityAccountDaoIRaffleActivityAccountDayDao 接口管理用户的抽奖次数:

  • 总次数限制
  • 月次数限制
  • 日次数限制

当用户参与抽奖时,会扣减相应的次数配额。

5. 完整抽奖流程总结

  1. 用户参与活动

    • 检查活动是否有效
    • 检查用户是否有抽奖资格(次数限制)
    • 创建抽奖单
  2. 执行抽奖

    • 获取未使用的抽奖单
    • 根据策略ID获取抽奖策略
    • 执行抽奖算法
    • 确定中奖结果
  3. 处理中奖结果

    • 更新抽奖单状态为已使用
    • 记录用户中奖信息
    • 创建消息通知任务
    • 发送中奖通知
  4. 异常处理

    • 事务管理确保数据一致性
    • 失败重试机制(通过任务表实现)
    • 日志记录

三、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、插入操作步骤

  1. 查找插入位置的前驱节点
    • 目标:确定新节点在每一层中的插入位置。

    • 方法:从最高层开始,逐层向右遍历,直到找到最后一个值小于待插入节点的节点,记录为该层的“前驱”。

    • 结果:得到一个前驱数组 predecessors,其中 predecessors[i] 表示第 i 层中最后一个小于待插入节点的节点。

  2. 随机生成新节点的层数
    • 规则:通过概率 p(如 0.25)决定层数。例如,每次有 p 的概率增加一层,直到达到最大层数 maxLevel

    • 意义:高层节点较少,平衡查找效率,避免退化为链表。

  3. 处理层数变化
    • 如果新节点的层数 newLevel 大于当前跳表的最大层数 currentLevel

    ◦ 将 currentLevel 更新为 newLevel

    ◦ 新增的前驱层(currentLevel + 1newLevel)的前驱设为头节点。

  4. 创建新节点并插入各层
    • 创建节点:新节点的层数为 newLevel,初始化所有层的指针为 null

    • 更新指针:

    ◦ 对每一层 i(从 0newLevel):

    ◦ 新节点的 `forward[i]` 指向 `predecessors[i].forward[i]`(原下一节点)。  
    
    ◦ `predecessors[i].forward[i]` 指向新节点。  
    

    • 更新跳表长度:总节点数加 1。

5、删除操作步骤

  1. 查找目标节点及其前驱
    • 目标:找到待删除节点 target 及其每一层的前驱节点 predecessors

    • 方法:类似插入操作,从最高层开始遍历,最终在最底层确认是否存在目标节点(需匹配 scoremember)。

  2. 移除目标节点
    • 遍历所有层:对每一层 i(从 0 到当前跳表的最大层数 currentLevel):

    ◦ 如果 predecessors[i].forward[i]target,则将其指针指向 target.forward[i],跳过 target

    • 意义:从所有层中移除 target,恢复链表连续性。

  3. 调整跳表层数
    • 如果最高层(currentLevel)的头节点指针为 null(即该层无节点):

    ◦ 将 currentLevel 减 1,直到找到存在节点的最高层。

    • 意义:保持跳表层数最小化,节省空间。

  4. 更新跳表长度:总节点数减 1。

6、关键细节

  1. 前驱节点的作用
    • 插入和删除时,必须记录每一层的前驱节点,否则无法正确更新指针,导致链表断裂。

  2. 随机层数的平衡性
    • 通过概率控制层数,高层节点稀疏,低层密集,保证跳表的查找效率接近 O(log n)

  3. 头节点的特殊处理
    • 头节点始终位于最高层,简化插入和删除的边界条件处理(如第一个节点的插入)。

  4. 删除后的层数调整
    • 若删除节点后,最高层无节点,需降低跳表层数,避免无效遍历。

7、示例流程

插入示例:插入 (score=2, member=C)

  1. 查找前驱:在最高层找到 Bscore=1),次高层同样找到 B
  2. 随机生成层数(假设为 2)。
  3. 更新指针:将 C 插入到 B 的下方,并在次高层链接到 A

删除示例:删除 (score=2, member=C)

  1. 查找确认 C 存在。
  2. 遍历所有层,将 B 的指针直接指向 AD
  3. 若最高层变为空,降低跳表层数。

8、时间复杂度

• 插入和删除:O(log n),需遍历各层查找前驱。

• 查找:O(log n),从最高层快速跳跃缩小范围。