前言
實際開發中有這樣的場景,使用者每日簽到,可獲取相對應的積分贈送,如果連續簽到,則可獲得額外的積分贈送。
本文主要講解使用點陣圖演算法來優化簽到歷史記錄的空間佔用。當然如果業務中僅僅是獲取連續簽到的最大天數,使用一個計數器即可記錄。
需求:
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欄位進行區分。
====================================