日志分析与活跃用户统计

100 阅读7分钟

日志分析与活跃用户统计

问题背景

我们需要分析一份接口访问日志,以统计特定条件下的“日活跃用户数 (DAU)”和“月活跃用户数 (MAU)”。

日志格式

日志记录在一个字符串数组 logs 中,每条日志的格式为 yyyy-mm-dd|client_ip|url|result,字段之间由竖线 | 分隔。

  • yyyy-mm-dd: 日志记录的日期。

    • 注意: 题目约定,所有月份都按 31 天计算。所有输入日志都在同一个月内,但日志本身不保证按日期排序。
  • client_ip: 客户端的IPv4地址。

    • 注意: 需要对IP地址进行标准化处理。点分十进制的每个部分中的前导零应被忽略。例如,1.01.001.11.1.1.1 应被视为同一个地址。
  • url: 访问的接口地址。

    • 格式示例: /login.do, /query.html。仅包含字母、./_
  • result: 访问结果。

    • 只有两种可能的值:successfail

统计规则

我们需要根据以下精确的规则来统计活跃用户数:

  1. 日活跃用户数 (Daily Active Users - DAU):

    对于指定的某一天,其“日活数”是指,在当天所有满足以下全部条件的日志中,不重复的 client_ip 的数量。

    • url 必须精确匹配 /login.do
    • result 必须是 success
  2. 月活跃用户数 (Monthly Active Users - MAU):

    其“月活数”是指,在当月所有天的日志中,所有满足上述相同条件的日志里,不重复的 client_ip 的数量。

    • 特别注意: 月活数不是每日日活数的简单相加,而是对整个月去重后的IP总数。

任务要求

给定一份日志 logs,请计算其对应的月活数和该月每一天的日活数。最终返回一个包含 32 个整数的序列:

  • 序列的第一个成员 ([0]) 是月活数
  • 后续的 31 个成员 ([1][31]) 依次是当月第 1 天到第 31 天的日活数

输入格式

  • logs: 一个字符串数组,代表日志列表。

    • 0 < logs.length <= 50000
    • 每条日志的长度 logs[i].length <= 150

输出格式

  • 一个包含 32 个整数的序列(数组或列表)。

样例说明

样例 1

  • 输入:

    ["2020-02-01|192.168.218.218|/login.do|success", "2020-02-01|192.168.218.218|/login.do|success", "2020-02-01|192.168.210.210|/login.do|fail", "2020-02-02|192.168.210.210|/login.do|success", "2020-02-02|192.168.218.218|/login.do|success"]
    
  • 输出: [2, 1, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]

  • 解释:

    1. 分析第1天 (2020-02-01) :

      • 有两条日志符合 urlresult 条件,但它们的 client_ip 都是 192.168.218.218
      • 去重后,当天活跃的独立IP只有 1 个。因此,第1天的日活数为 1
    2. 分析第2天 (2020-02-02) :

      • 有两条日志符合条件,它们的 client_ip 分别是 192.168.210.210192.168.218.218
      • 去重后,当天活跃的独立IP有 2 个。因此,第2天的日活数为 2
    3. 分析整月:

      • 所有天中,满足条件的独立IP共有 192.168.218.218192.168.210.210 这两个。
      • 因此,月活数为 2
    4. 组合输出: [月活, 日活1, 日活2, ...] -> [2, 1, 2, 0, ...]

样例 2

  • 输入:

    ["2020-12-01|192.168.218.001|/login.do|success", "2020-12-01|192.168.218.1|/login.do|success", "2020-12-01|192.168.218.2|/to_login.do|success"]
    
  • 输出: [1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]

  • 解释:

    1. 分析第1天 (2020-12-01) :

      • 日志1: 192.168.218.001 标准化后为 192.168.218.1,URL和结果符合。
      • 日志2: 192.168.218.1,URL和结果符合。
      • 日志3: url/to_login.do,不符合 == /login.do 的条件,被忽略。
      • 符合条件的独立IP只有 192.168.218.1 这一个。第1天的日活数为 1
    2. 分析整月:

      • 整个月中,满足条件的独立IP也只有 192.168.218.1 这一个。月活数为 1
    3. 组合输出: [1, 1, 0, ...]

import java.util.*;
import java.util.stream.Collectors;

public class Solution {
    /**
     * 根据日志记录统计该月的日活数和月活数。
     *
     * 算法思想:
     * 1.  **IP 规范化**: 由于 "1.1.1.1" 和 "1.01.001.1" 应视为同一地址,我们需要一个函数
     * 将 IP 地址转换为唯一的、规范的格式。方法是按 '.' 分割,将每部分转为整数再转回字符串,
     * 最后重新用 '.' 连接。例如 "1.01.1" -> [1, 1, 1] -> "1.1.1"。
     *
     * 2.  **数据结构选择**:
     * -   **日活 (DAU)**: 我们需要记录每一天有哪些**不同**的 IP 成功登录过。使用一个
     * `Map<Integer, Set<String>>` 非常合适。Map 的 Key 是天 (1-31),
     * Value 是一个 `Set`,存储当天所有成功登录的、规范化的 IP 地址。Set 自动处理了当天内的 IP 去重。
     * -   **月活 (MAU)**: 我们需要记录整个月有哪些**不同**的 IP 成功登录过。使用一个
     * `Set<String>` 就可以存储所有成功登录的、规范化的 IP 地址,自动完成月度去重。
     *
     * 3.  **处理流程**:
     * a. 遍历每一条日志。
     * b. 对每一条日志,检查是否满足活跃条件 (`url` 是 "/login.do" 且 `result` 是 "success")。
     * c. 如果满足条件:
     * i.  解析出日期中的 "天"。
     * ii. 解析并规范化 IP 地址。
     * iii. 将规范化的 IP 加入对应日期的 `Set` (更新日活统计)。
     * iv. 将规范化的 IP 加入代表月活的 `Set` (更新月活统计)。
     * d. 遍历结束后,`月活Set` 的大小就是 MAU,每个 `日活Set` 的大小就是对应那一天的 DAU。
     *
     * 4.  **构建输出**: 创建一个长度为 32 的数组,将 MAU 和 DAU 填入相应位置。
     *
     * @param logs 日志记录的字符串数组。
     * @return 一个长度为 32 的整数数组,`result[0]` 为月活数,`result[i]` (i > 0) 为第 i 天的日活数。
     */
    public int[] analyzeLog(String[] logs) {
        // --- 1. 初始化数据结构 ---

        // 用于统计日活:Key=天(1-31), Value=当天活跃的唯一IP集合
        Map<Integer, Set<String>> dailyActiveUsers = new HashMap<>();

        // 用于统计月活:存储本月所有活跃过的唯一IP
        Set<String> monthlyActiveUsers = new HashSet<>();

        // --- 2. 遍历并处理日志 ---
        for (String log : logs) {
            // a. 分割日志字符串,注意|是特殊字符
            String[] parts = log.split("\|");
            // 确保日志格式正确,有4个部分
            if (parts.length != 4) {
                continue; // 格式不符,跳过
            }

            // b. 提取字段
            String dateStr = parts[0];
            String ipStr = parts[1];
            String url = parts[2];
            String result = parts[3];

            // c. 检查是否为成功的登录事件
            if ("/login.do".equals(url) && "success".equals(result)) {
                
                // d. 如果是,则提取信息并更新统计
                try {
                    // 提取日期中的“天”
                    // lastIndexOf('-') 找到最后一个 '-',其后就是天的部分
                    int day = Integer.parseInt(dateStr.substring(dateStr.lastIndexOf('-') + 1));
                    
                    // 规范化 IP 地址
                    String normalizedIp = normalizeIp(ipStr);
                    
                    // 更新日活统计:
                    // computeIfAbsent: 如果 day 不在 map 中,则创建一个新的 HashSet,
                    // 否则返回现有的 Set。然后将 IP 加入该 Set。
                    dailyActiveUsers.computeIfAbsent(day, k -> new HashSet<>()).add(normalizedIp);
                    
                    // 更新月活统计
                    monthlyActiveUsers.add(normalizedIp);

                } catch (NumberFormatException | StringIndexOutOfBoundsException e) {
                    // 如果日期或IP格式解析错误,打印错误信息并跳过此条日志
                    System.err.println("跳过格式错误的日志: " + log + " | 错误: " + e.getMessage());
                }
            }
        }

        // --- 3. 构建输出数组 ---
        int[] output = new int[32]; // 索引 0 用于月活,1-31 用于日活

        // a. 填入月活数
        output[0] = monthlyActiveUsers.size();

        // b. 填入日活数
        // 遍历 1 到 31 天
        for (int day = 1; day <= 31; day++) {
            // 从 Map 中获取当天的活跃用户集合
            Set<String> dailySet = dailyActiveUsers.get(day);
            if (dailySet != null) {
                // 如果当天有活跃记录,则集合的大小即为日活数
                output[day] = dailySet.size();
            }
            // 如果当天没有活跃记录,数组对应位置默认为 0,无需操作
        }

        return output;
    }

    /**
     * 辅助方法:将一个点分十进制的 IP 字符串规范化。
     * 例如,"1.01.001.1" 会被转换为 "1.1.1.1"。
     *
     * @param ipStr 原始 IP 字符串
     * @return 规范化后的 IP 字符串
     */
    private String normalizeIp(String ipStr) {
        // 使用 Stream API 简洁地实现
        // 1. 按 '.' 分割
        // 2. 将每个部分转换为 int (这会自动去除前导0,例如 "01" -> 1)
        // 3. 将 int 转回 String
        // 4. 用 '.' 重新连接
        return Arrays.stream(ipStr.split("\."))
                     .map(s -> String.valueOf(Integer.parseInt(s)))
                     .collect(Collectors.joining("."));
    }
}