数据结构 Bitmap(位图)示例 - 用户签到系统

下面提供一个基于 Java BitSet 的完整用户签到系统设计方案,涵盖需求分析、核心思路、关键代码与测试示例。


一、设计思路

1. 需求定义

  • 用户每天可以签到一次,重复签到不会覆盖或重复计数。
  • 支持查询任意用户在某一天是否已签到。
  • 统计某个月份的签到总天数。
  • 查询截至某一天的连续签到天数(按自然日连续,中间不能断开)。

2. 存储模型

使用 Map<Long, BitSet> 存储每个用户的签到记录:

  • Key:用户ID(Long)
  • ValueBitSet,其中每个 bit 代表一天。
    • 位索引 = 从「基准日期」到签到日期的天数差
    • 基准日期固定为 2020-01-01,也可以调整为系统上线日期。
    • 优点:支持任意未来日期,BitSet 会自动扩容,空间利用率高。

内存估算

假设系统运行 10 年(约 3652 天),每个用户的 BitSet 约 3652 bits ≈ 457 字节

1000 万活跃用户 → 约 4.3 GB 内存,可接受(实际生产会使用 Redis + Bitmap,但本例展示内存实现)。

3. 核心操作时间复杂度

  • 签到/查询:O(1)
  • 月签到统计:O(当月天数)
  • 连续签到统计:最坏 O(连续天数) (实际会提前终止)

4. 线程安全

使用 ConcurrentHashMap 存储,对每个用户的 BitSet 进行读/写时加锁(synchronized),避免并发修改异常。


二、完整 Java 代码

java 复制代码
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
 * 基于 BitSet 的用户签到系统
 * 功能:签到、检查、月签到统计、连续签到天数
 */
public class SignInService {

    // 基准日期:系统上线日,所有签到天数偏移基于这一天
    private static final LocalDate BASE_DATE = LocalDate.of(2020, 1, 1);

    // 存储每个用户的签到位图
    private final Map<Long, BitSet> userSignMap = new ConcurrentHashMap<>();

    /**
     * 用户签到
     * @param userId 用户ID
     * @param date   签到日期
     * @return 是否签到成功(若当天已签到返回false)
     */
    public boolean signIn(Long userId, LocalDate date) {
        // 不能签未来的到
        if (date.isAfter(LocalDate.now())) {
            throw new IllegalArgumentException("不能签未来的日期");
        }
        int offset = getOffset(date);
        BitSet bitSet = userSignMap.computeIfAbsent(userId, k -> new BitSet());
        synchronized (bitSet) {
            if (bitSet.get(offset)) {
                return false;   // 已签到
            }
            bitSet.set(offset);
            return true;
        }
    }

    /**
     * 检查用户在某天是否已签到
     */
    public boolean isSigned(Long userId, LocalDate date) {
        BitSet bitSet = userSignMap.get(userId);
        if (bitSet == null) {
            return false;
        }
        int offset = getOffset(date);
        synchronized (bitSet) {
            return bitSet.get(offset);
        }
    }

    /**
     * 获取用户某个月的签到总天数
     * @param userId 用户ID
     * @param year   年份
     * @param month  月份 (1-12)
     */
    public int getMonthlySignCount(Long userId, int year, int month) {
        BitSet bitSet = userSignMap.get(userId);
        if (bitSet == null) {
            return 0;
        }
        LocalDate firstDay = LocalDate.of(year, month, 1);
        LocalDate lastDay = firstDay.withDayOfMonth(firstDay.lengthOfMonth());
        int startOffset = getOffset(firstDay);
        int endOffset = getOffset(lastDay);
        int count = 0;
        synchronized (bitSet) {
            for (int offset = startOffset; offset <= endOffset; offset++) {
                if (bitSet.get(offset)) {
                    count++;
                }
            }
        }
        return count;
    }

    /**
     * 获取用户截至某一天的连续签到天数(包含当天)
     * 连续定义:从当天向前追溯,直到第一个未签到的日期为止
     * @param userId 用户ID
     * @param date   截止日期
     */
    public int getContinuousDays(Long userId, LocalDate date) {
        BitSet bitSet = userSignMap.get(userId);
        if (bitSet == null) {
            return 0;
        }
        int continuous = 0;
        LocalDate cur = date;
        while (true) {
            int offset = getOffset(cur);
            synchronized (bitSet) {
                if (!bitSet.get(offset)) {
                    break;
                }
            }
            continuous++;
            cur = cur.minusDays(1);
        }
        return continuous;
    }

    // 计算日期相对于基准日期的偏移量(天数)
    private int getOffset(LocalDate date) {
        return (int) ChronoUnit.DAYS.between(BASE_DATE, date);
    }

    // ---------- 测试 Demo ----------
    public static void main(String[] args) {
        SignInService service = new SignInService();
        Long userId = 10086L;

        // 签到示例
        LocalDate today = LocalDate.now();
        LocalDate yesterday = today.minusDays(1);
        LocalDate twoDaysAgo = today.minusDays(2);
        LocalDate threeDaysAgo = today.minusDays(3);

        service.signIn(userId, threeDaysAgo);  // 3天前签到
        service.signIn(userId, yesterday);     // 昨天签到
        service.signIn(userId, today);         // 今天签到

        // 查询某天是否签到
        System.out.println("今天是否签到? " + service.isSigned(userId, today));
        System.out.println("两天前是否签到? " + service.isSigned(userId, twoDaysAgo));

        // 月签到统计(当前月份)
        int monthCount = service.getMonthlySignCount(userId, today.getYear(), today.getMonthValue());
        System.out.println("本月累计签到天数: " + monthCount);

        // 连续签到天数(截至今天)
        int continuous = service.getContinuousDays(userId, today);
        System.out.println("连续签到天数: " + continuous);

        // 再签一天(明天不能签,演示会抛异常)
        // service.signIn(userId, today.plusDays(1));  // 抛出 IllegalArgumentException
    }
}

代码说明

方法 实现要点
signIn() 计算偏移量,对 BitSet 加锁后设置位,返回是否新签到。
isSigned() 获取 BitSet,加锁后检查指定位。
getMonthlySignCount() 通过月份首尾日期计算偏移范围,遍历累加。
getContinuousDays() 从指定日期向前循环检查,直到遇到未签到日停止。
getOffset() 利用 ChronoUnit.DAYS.between 计算与基准日期的天数差。

三、扩展与优化建议

1. 替换 BitSet 为 RoaringBitmap(应对稀疏或超长周期)

  • 当用户生命周期长达几十年(如 50 年 ≈ 18262 天),普通 BitSet 内存约 2.2KB/用户,仍可接受。
  • 若出现天窗 (用户极少签到),可改用 RoaringBitmap 压缩存储,但对连续天数查询性能略有影响。

2. 持久化与生产部署

  • 内存版仅适合单机演示或极小型应用。生产环境 推荐:
    • Redis BitmapSETBIT / GETBIT / BITCOUNT / BITPOS)天然支持位操作,且可持久化、集群。
    • 数据库定期将 Redis 数据备份到 MySQL/HBase,用于离线分析。

3. 连续天数查询优化

目前最坏情况遍历连续天数(通常 ≤ 365 天),性能无问题。如需极高并发,可对每个用户额外维护一个连续签到计数器 (例如在签到当天更新 user:sign:streak:userId),做到 O(1) 查询。

4. 跨月/跨年连续签到

上述 getContinuousDays 基于日期减法,自动处理跨月、跨年,无需额外逻辑。

5. 日活统计

利用 BitSetcardinality() 方法可以快速统计某天全局签到人数,但需遍历所有用户的 BitSet,适合定时任务而非实时。


四、运行示例输出(假设当前日期 2026-05-22)

复制代码
今天是否签到? true
两天前是否签到? false
本月累计签到天数: 3
连续签到天数: 2   // 注意:3天前签过,但前天未签,所以截至今天连续是 昨天+今天 = 2

(根据实际签到日期,输出可能略有不同)


以上代码完整实现了基于 BitSet 的用户签到系统,清晰展示了位图在海量布尔状态存储中的优势,可直接复制运行验证。

相关推荐
就叫_这个吧1 小时前
Java线程池应用的四种方式+线程池底层实现原理
java·开发语言
洛水水1 小时前
Redis对象类型与底层数据结构
数据结构·数据库·redis
Rust研习社1 小时前
Rust 官方拟定 LLM 政策,防止 LLM 污染开源社区?
开发语言·后端·ai·rust·开源
muqsen1 小时前
Java 分布式相关面试题总结
java·开发语言·分布式
Hesionberger1 小时前
LeetCode114:二叉树展开为链表(三解法)
数据结构
一行代码一行诗++1 小时前
循环的嵌套
数据结构·算法
fenglllle1 小时前
JDK8升级JDK17使用CompletableFuture在线程中classloader的变化
java·开发语言·jvm
froginwe111 小时前
Scala 正则表达式
开发语言
时寒的笔记1 小时前
11期_js逆向核心案例解析(sichuan&某理财网)
开发语言·javascript·ecmascript