使用點陣圖演算法來優化簽到歷史儲存空間佔用

冬眠的山谷發表於2019-06-27

 

前言

實際開發中有這樣的場景,使用者每日簽到,可獲取相對應的積分贈送,如果連續簽到,則可獲得額外的積分贈送。

本文主要講解使用點陣圖演算法來優化簽到歷史記錄的空間佔用。當然如果業務中僅僅是獲取連續簽到的最大天數,使用一個計數器即可記錄。

 

需求:

1.記錄一年的簽到歷史

2.獲取某月的簽到歷史

3.獲取過去幾天連續簽到的最大天數

 

點陣圖演算法實現思路

一天的簽到狀態只有兩種,簽到和未簽到。如果使用一個位元組來表示,就需要最多366個位元組。如果只用一位來表示僅需要46(366/8 = 45.75)個位元組。

點陣圖演算法最關鍵的地方在於定位。 也就是說陣列中的第n bit表示的是哪一天。給出第n天,如何查詢到第n天的bit位置。 

這裡使用除法和求餘來定位。

比如上圖

第1天,index = 1/8 =  0, offset = 1 % 8 = 1 ,也就是第0個陣列的第1個位置(從0開始算起)。

第11天,index = 11/8 =  1, offset = 11 % 8 = 3 ,也就是第1個陣列的第3個位置(從0開始算起)。

        byte[] decodeResult = signHistoryToByte(signHistory);
         //index 該天所在的陣列位元組位置
        int index = dayOfYear / 8;
       //該天在位元組中的偏移量
        int offset = dayOfYear % 8;
//設定該天所在的bit為1
     byte data = decodeResult[index];
data = (byte)(data|(1 << (7-offset)));
decodeResult[index] = data ;

    //獲取該天所在的bit的值
int flag = data[index] & (1 << (7-offset)); 

編碼問題

應用中使用的位元組陣列,而存到資料庫的是字串。

由於ASCII表中有很多不可列印的ASCII值,並且每一個簽到記錄的位元組都是-128~127,如果使用String 來進行轉碼,會造成亂碼出現,

亂碼

public static void main(String args[]){

       byte[] data = new byte[1];
       for(int i = 0; i< 127; i++){
           data[0] = (byte)i;
           String str =  new String(data);
           System.out.println(data[0] + "---" + str);
       }

       data[0] = -13;
       String str =  new String(data);
       System.out.println(data[0] + "---" + str + "----");


   }

/////////////////////////
0--- 
1---
2---
3---
4---
5---
6---
7---
8--
9---    
10---

11---
12---

 

為了解決編碼亂碼問題,

本文使用BASE64編碼來實現。參看 

Base64 的那些事兒

LocalDate

Date類並不能為我們提供獲取某一天是該年的第幾天功能,JDK8為我們提供了LocalDate類,該類可以替換Date類,相比Date提供了更多豐富的功能。更多使用方法參考原始碼。

 //獲取2018/6/11 位於該年第幾天
       LocalDate localDate  = LocalDate.of(2018,6,11);
       localDate.getDayOfYear();

       //獲取今天 位於當年第幾天
       LocalDate localDate1  = LocalDate.now();
       localDate.getDayOfYear();

 

資料表

原始陣列長度僅需要46個位元組,經過BASE64編碼後的字串長度為64,所以這裡的sign_history長度最大為64.

DROP TABLE IF EXISTS `sign`;
CREATE TABLE `sign`(
   `id` BIGINT   AUTO_INCREMENT COMMENT "ID",
   `user_id` BIGINT  DEFAULT NULL COMMENT "使用者ID",
   `sign_history` VARCHAR(64) DEFAULT NULL COMMENT "簽到歷史",
   `sign_count` INT DEFAULT 0 COMMENT "連續簽到次數" ,
   `last_sign_time` TIMESTAMP DEFAULT  CURRENT_TIMESTAMP COMMENT "最後簽到時間",
    PRIMARY KEY (`id`),
    UNIQUE user_id_index (`user_id`)

)ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT="簽到表";

 

簽到

由於每一天在簽到歷史記錄的位元組陣列中的位置都是固定好的。因此可以通過對該天進行除法和求餘,即可輕易計算出該天所在的bit.

 對該天簽到僅需將該bit置1即可。之後對位元組陣列進行重新BASE64編碼即可

/**
     *功能描述
     * @author lgj
     * @Description   簽到
     * @date 6/27/19
     * @param:   signHistory: 原始簽到字串
     *           dayOfYear: 需要簽到的那一天(那年的第幾天)
     * @return:  最新生成的簽到歷史字串
     *
    */
    public static String sign(String signHistory,int dayOfYear) throws Exception {

        if(signHistory == null){
            throw new SignException("SignHistory can not be null!");
        }
        checkOutofDay(dayOfYear);

        byte[] decodeResult = signHistoryToByte(signHistory);
//index 該天所在的陣列位元組位置
int index = dayOfYear / 8;
//該天在位元組中的偏移量
int offset = dayOfYear % 8; byte data = decodeResult[index]; data = (byte)(data|(1 << (7-offset))); decodeResult[index] = data ; String encodeResult = new BASE64Encoder().encode(decodeResult); return encodeResult; }

獲取某年某月的簽到資料

該功能實現先求出當月第一天和最後一天屬於當年的第幾天,然後遍歷該範圍內的簽到情況。 

/**
     *功能描述
     * @author lgj
     * @Description   獲取某年某月的簽到資料
     * @date 6/27/19
     * @param:    List<Integer>,如果當月的第一天和第三天簽到,返回[1,3]
     * @return:
     *
    */
    public static List<Integer> getSignHistoryByMonth(String signHistory, int year, int month)throws Exception{

        if(signHistory == null){
            throw new SignException("SignHistory can not be null!");
        }
        checkOutofMonth(month);
        //start 本月第一天屬於當年的第幾天
        LocalDate localDate =  LocalDate.of(year,month,1);
        int start = localDate.getDayOfYear();
        //end 本月的最後一天屬於當年的第幾天
        int dayOfMonth = localDate.lengthOfMonth();
        //log.info("dayOfMonth = {}",dayOfMonth);
        localDate = localDate.withDayOfMonth(dayOfMonth);
        int end = localDate.getDayOfYear();

        //log.info("start={},end={}",start,end);
        Integer result = 0;

        byte[] data = signHistoryToByte(signHistory);

        List<Integer> signDayOfMonths = new ArrayList<>();

        int signDay = 0;
     //遍歷
for(int i = start; i< end ; i++){ signDay++; if(isSign(data,i)){ signDayOfMonths.add(signDay); } } return signDayOfMonths; }

 

獲取過去幾天的連續簽到的次數

先定位當天的bit所在的bit位置,再往前遍歷,直到碰到沒有簽到的一天。 

/**
     *功能描述
     * @author lgj
     * @Description   獲取過去幾天的連續簽到的次數
     * @date 6/27/19
     * @param:
     * @return:   今天 6.27 簽到, 同時 6.26 ,6.25 也簽到 ,6.24 未簽到 ,返回 3
     *            今天 6.27 未簽到, 同時 6.26 ,6.25 也簽到 ,6.24 未簽到 ,返回 2
     *
    */
    public static int  getMaxContinuitySignDay(String signHistory) throws Exception{

        int maxContinuitySignDay = 0;

        if(signHistory == null){
            throw new SignException("SignHistory can not be null!");
        }
        //獲取當天所在的年偏移量
        LocalDate localDate =LocalDate.now();
        int curDayOfYear = localDate.getDayOfYear();

        byte[] data = signHistoryToByte(signHistory);

//開始遍歷,從昨天往前遍歷
int checkDayOfYear = curDayOfYear-1; while (checkDayOfYear > 0){ if(isSign(data,checkDayOfYear)){ checkDayOfYear--; maxContinuitySignDay++; } else { break; } } //檢測今天是否已經簽到,簽到則+1 if(isSign(data,curDayOfYear)){ maxContinuitySignDay +=1; } return maxContinuitySignDay; }

 

測試某年的第n天是否簽到

和上面一樣先定位當天的bit所在的位置,再獲取該bit的值,如果為1則說明已經簽到,否則為0說明沒簽到。

/**
     *功能描述
     * @author lgj
     * @Description  測試某年的第n天是否簽到
     * @date 6/27/19
     * @param:  true: 該天簽到 false:沒有簽到
     * @return:
     *
    */
    public static boolean isSign(byte[] data,int dayOfYear) throws Exception{

        checkOutofDay(dayOfYear);
        int index = dayOfYear / 8;
        int offset = dayOfYear % 8;
        //System.out.print(index+"-");
        int flag = data[index] & (1 << (7-offset));

        return flag == 0?false:true;

    }

其他程式碼

//獲取預設值,所有的bit都為0,也就是沒有任何的簽到資料
public
static String defaultsignHistory(){ byte[] encodeData = new byte[46]; return new BASE64Encoder().encode(encodeData); } //簽到歷史字串轉位元組陣列 public static byte[] signHistoryToByte(String signHistory) throws Exception { if(signHistory == null){ throw new SignException("SignHistory can not be null!"); } return new BASE64Decoder().decodeBuffer(signHistory); }
//校驗天是否超出範圍 0- 365|366
private static void checkOutofDay(int dayOfYear) throws Exception{ LocalDate localDate =LocalDate.now(); int maxDay = localDate.isLeapYear()?366:365; if((dayOfYear <= 0)&&( dayOfYear > maxDay)){ throw new SignException("The param dayOfYear["+dayOfYear+"] is out of [0-"+ maxDay+"]"); } }
//校驗月數是否超出範圍
private static void checkOutofMonth(int month) throws Exception{ if((month <= 0)&&( month > 12)){ throw new SignException("The param month["+month+"] is out of [0-"+ 12+"]"); } }

 

測試

測試1

@Test
public void sign() throws Exception{

String signHistory = SignHistoryUtil.defaultsignHistory();


int signMonth = 8;
int signDay = 13;
int dayOfYear0 = LocalDate.of(2019,signMonth,signDay).getDayOfYear();
log.info("對2019-"+ signMonth + "-"+signDay+",第[" + dayOfYear0 + "]天簽到!");
signHistory = SignHistoryUtil.sign(signHistory,dayOfYear0);


signMonth = 8;
signDay = 24;
int dayOfYear1 = LocalDate.of(2019,signMonth,signDay).getDayOfYear();
log.info("對2019-"+ signMonth + "-"+signDay+",第[" + dayOfYear1 + "]天簽到!");
signHistory = SignHistoryUtil.sign(signHistory,dayOfYear1);



byte[] data = SignHistoryUtil.signHistoryToByte(signHistory);


System.out.println();

log.info("第[{}]天是否簽到:{}",dayOfYear0,SignHistoryUtil.isSign(data,dayOfYear0));
log.info("第[{}]天是否簽到:{}",dayOfYear1,SignHistoryUtil.isSign(data,dayOfYear1));

log.info("第[{}]天是否簽到:{}",15,SignHistoryUtil.isSign(data,16));


log.info("簽到結果:");
log.info("陣列長度 = " + data.length);
for(int i = 0; i< data.length; i++){

System.out.print(data[i]);
}
System.out.println();
log.info("signHistory 長度:[{}],VALUE=[{}]",signHistory.length(),signHistory);
List<Integer> signDayOfMonths = SignHistoryUtil.getSignHistoryByMonth(signHistory,2019,signMonth);

log.info("第[{}]月簽到記錄[{}]",signMonth,signDayOfMonths);
}

 

輸出

14:09:23.493 [main] INFO com.microblog.points.service.strategy.SignHistoryUtilTest - 對2019-8-13,第[225]天簽到!
14:09:23.529 [main] INFO com.microblog.points.service.strategy.SignHistoryUtilTest - 對2019-8-24,第[236]天簽到!

14:09:23.531 [main] INFO com.microblog.points.service.strategy.SignHistoryUtilTest - 第[225]天是否簽到:true
14:09:23.535 [main] INFO com.microblog.points.service.strategy.SignHistoryUtilTest - 第[236]天是否簽到:true
14:09:23.535 [main] INFO com.microblog.points.service.strategy.SignHistoryUtilTest - 第[15]天是否簽到:false
14:09:23.535 [main] INFO com.microblog.points.service.strategy.SignHistoryUtilTest - 簽到結果:
14:09:23.536 [main] INFO com.microblog.points.service.strategy.SignHistoryUtilTest - 陣列長度 = 46
00000000000000000000000000006480000000000000000
14:09:23.542 [main] INFO com.microblog.points.service.strategy.SignHistoryUtilTest - signHistory 長度:[64],VALUE=[AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAIAAAAAAAAAAAAAAAAAAAAAA==]
14:09:23.545 [main] INFO com.microblog.points.service.strategy.SignHistoryUtilTest - 第[8]月簽到記錄[[13, 24]]

Process finished with exit code 0

 

測試2

@Test
    public void getMaxContinuitySignDay()throws Exception {

        String signHistory = SignHistoryUtil.defaultsignHistory();

        int curMonth = LocalDate.now().getMonth().getValue();
        int curDay = LocalDate.now().getDayOfMonth();

        int signDayCount = 0;
        int maxCount = 5;
        while(signDayCount < maxCount){
            LocalDate localDate  = LocalDate.of(2019,curMonth,curDay-signDayCount);
            log.info("[{}]簽到",localDate);
            signHistory = SignHistoryUtil.sign(signHistory,localDate.getDayOfYear());
            signDayCount++;
        }

        LocalDate localDate  = LocalDate.of(2019,curMonth,curDay-signDayCount-1);
        log.info("[{}]簽到",localDate);
        signHistory = SignHistoryUtil.sign(signHistory,localDate.getDayOfYear());


       int  maxContinuitySignDay = SignHistoryUtil.getMaxContinuitySignDay(signHistory);
        log.info("連續簽到[{}]天!",maxContinuitySignDay);



    }

輸出

14:11:02.340 [main] INFO com.microblog.points.service.strategy.SignHistoryUtilTest - [2019-06-27]簽到
14:11:02.351 [main] INFO com.microblog.points.service.strategy.SignHistoryUtilTest - [2019-06-26]簽到
14:11:02.352 [main] INFO com.microblog.points.service.strategy.SignHistoryUtilTest - [2019-06-25]簽到
14:11:02.353 [main] INFO com.microblog.points.service.strategy.SignHistoryUtilTest - [2019-06-24]簽到
14:11:02.354 [main] INFO com.microblog.points.service.strategy.SignHistoryUtilTest - [2019-06-23]簽到
14:11:02.355 [main] INFO com.microblog.points.service.strategy.SignHistoryUtilTest - [2019-06-21]簽到
14:11:02.355 [main] INFO com.microblog.points.service.strategy.SignHistoryUtilTest - 連續簽到[5]天!

 

注意: 本文例項程式碼中並未考慮跨年度的情況,sign_history欄位僅支援儲存當年(1月1號--12月31號)的日簽到資料,如果需要跨年度需求,在資料表中新增year欄位進行區分。

本文完整程式碼  實現程式碼  測試程式碼

 

====================================

相關文章