返回

PHP数组按列分组聚合:高效汇总数据与补零技巧

php

好的,这是你要的博客文章内容:

PHP 数组处理:按指定列分组并聚合数据

——将二维数组按名称分组,汇总周数据并补全缺失周

写代码的时候,经常会跟各种数组打交道。有时候,拿到的原始数据格式,跟咱们最终需要的不太一样,就需要做一些转换。

问题来了

假设你手头有一个 PHP 数组,记录了不同客户在不同周的工作量(或者别的什么数值)。它看起来像这样:

$hours = [
    ['amount' => 75.00, 'name' => 'CLIENT1', 'week' => 22],
    ['amount' => 945.00, 'name' => 'CLIENT1', 'week' => 23],
    ['amount' => 45.00, 'name' => 'CLIENT1', 'week' => 24],
    // ... 可能还有更多 CLIENT1 的记录
    ['amount' => 45.00, 'name' => 'CLIENTX', 'week' => 22],
    ['amount' => 15.00, 'name' => 'CLIENTX', 'week' => 22], // 注意!同一周有两条记录
    // CLIENTX 在第 23 周没有记录
    ['amount' => 73.00, 'name' => 'CLIENTX', 'week' => 24],
    // ... 可能还有其他客户的数据
];

这个数组是个简单的列表,每个元素是关联数组,包含 amount(数量)、name(客户名)和 week(周数)。

现在,你需要把它转换成下面这种结构:

$result = [
    'weeks' => [22, 23, 24], // 数据中出现过的所有周数,排好序
    'series' => [
        [
            'name' => 'CLIENT1',
            'data' => [75.00, 945.00, 45.00] // CLIENT1 在 22, 23, 24 周的数据
        ],
        [
            'name' => 'CLIENTX',
            'data' => [60.00, 0, 73.00] // CLIENTX 在 22 周的数据是 45+15=60,23 周没有记录填 0,24 周是 73
        ],
        // ... 其他客户的数据
    ]
];

目标结构包含两部分:

  1. weeks: 一个包含所有出现过的周数的数组,按升序排列。
  2. series: 一个数组,每个元素代表一个客户。包含客户名 (name) 和一个按 weeks 顺序排列的数据数组 (data)。
    • 如果同一客户在同一周有多条记录,amount 需要 相加
    • 如果某个客户在某个周数没有任何记录,data 数组对应位置的值应为 0

为啥会卡壳?

你可能尝试过直接遍历原始数组来构建新结构,比如像下面这样:

$clientArray = [];
$weekAmount = []; // 这个变量的使用方式可能有问题
foreach($hours as $hour){
    $client = $hour['name'];
    $amount = $hour['amount'];
    $week = $hour['week'];

    // 尝试记录每个客户每周的数据?
    if(!array_key_exists($week, $weekAmount)){
        $weekAmount[$week] = 0; // 这里初始化逻辑有问题
    }
    $weekAmount[$week] = $amount; // 这里直接覆盖了,没有累加!
    $clientArray[$client] = $weekAmount; // 这里每次都用 $weekAmount 覆盖,前面的客户数据也没了
    ksort($clientArray[$client]);
}
// 这段代码距离目标还差得远...

这段代码主要有几个问题:

  1. 覆盖而非累加 : $weekAmount[$week] = $amount; 会用当前记录的 amount 直接覆盖掉之前可能存在的同周数据,而不是把它们加起来。像 CLIENTX 在第 22 周有两条记录,这里只会留下最后一条 15.00
  2. $weekAmount 变量混用 : $weekAmount 数组在循环中被不清空地复用,并且直接赋值给 $clientArray[$client]。这会导致后续客户的数据会错误地包含前面客户处理过的周数据,或者干脆被完全覆盖。
  3. 缺少周信息 : 最终生成的 $clientArray 里,只有客户名作为键,值是一个以周数为键、amount 为值的数组。这还不是最终需要的 ['name' => ..., 'data' => [...]] 结构。
  4. 缺失周补 0 问题未解决 : 代码没有处理某个客户在某些周数完全没有数据的情况,没法自动补 0
  5. 全局周列表未生成 : 没有提取所有唯一的周数。

简单说,直接一次遍历想搞定所有事情比较绕,特别是处理累加和补 0 的逻辑。

解决方案来了:分步处理思路

面对这种数据转换,把任务拆解成几个清晰的步骤通常更靠谱。

核心思路:

  1. 准备基础信息:
    • 找出所有出现过的唯一周数,并排序。
    • 创建一个中间数据结构,用来按客户和周聚合(累加)amount
  2. 聚合数据: 遍历原始数组 $hours,填充这个中间结构。遇到相同客户相同周的数据,就把 amount 加起来。
  3. 构建最终结果: 遍历中间聚合好的数据,参照第一步得到的完整周列表,为每个客户生成 data 数组,缺少的周就填 0

下面是具体的实现步骤和代码。

第一步:提取所有周数并初始化聚合结构

咱们先拿到全局的周列表,并准备一个地方存放按客户分组聚合后的数据。

<?php

// 假设 $hours 数组已经定义好了,就像问题里那样

// 1. 提取所有周数并排序
$allWeeks = array_column($hours, 'week'); // 取出所有 'week' 的值
$allWeeks = array_unique($allWeeks);      // 去重
sort($allWeeks);                         // 排序(默认升序)

// 2. 初始化用于聚合数据的数组
$aggregatedData = [];
// 它的结构会是这样:
// $aggregatedData = [
//     'CLIENT1' => [22 => 75.00, 23 => 945.00, 24 => 45.00],
//     'CLIENTX' => [22 => 60.00, 24 => 73.00] // 注意:23周缺失,22周是累加结果
// ];

// 打印看看第一步的结果(可选)
// echo "所有周数 (All Weeks):\n";
// print_r($allWeeks);

代码解释:

  • array_column($hours, 'week'): 这个函数很方便,能从二维数组里直接提取指定键('week')的所有值,生成一个新的一维数组。
  • array_unique(): 去掉重复的周数。
  • sort(): 对周数进行升序排序,确保后面生成的 data 数组顺序一致。
  • $aggregatedData: 初始化一个空数组,后面会用客户名作为键,值是另一个关联数组(周数 => 该周累加的 amount)。

第二步:遍历原始数据,聚合 Amount

现在循环处理 $hours 数组,把数据填充到 $aggregatedData 里。

<?php
// 接上一步的代码

// 3. 遍历原始数据进行聚合
foreach ($hours as $record) {
    $client = $record['name'];
    $week = $record['week'];
    $amount = (float)$record['amount']; // 确保 amount 是数值类型

    // 检查当前客户是否已在聚合数组中
    if (!isset($aggregatedData[$client])) {
        $aggregatedData[$client] = []; // 如果是新客户,初始化一个空数组
    }

    // 检查当前客户的当前周是否已有数据
    if (!isset($aggregatedData[$client][$week])) {
        $aggregatedData[$client][$week] = 0; // 如果是该客户本周第一条记录,初始化为 0
    }

    // 累加 amount
    $aggregatedData[$client][$week] += $amount;
}

// 打印看看聚合后的结果(可选)
// echo "\n聚合后的数据 (Aggregated Data):\n";
// print_r($aggregatedData);

代码解释:

  • 循环遍历 $hours 中的每条记录。
  • 获取客户名、周数、数量。用 (float) 强制转换 amount,避免潜在的类型问题导致加法出错。
  • isset($aggregatedData[$client]): 检查这个客户是不是第一次遇到。不是就不用操作,是的话,给这个客户在 $aggregatedData 里创建一个空数组,准备存放他的周数据。
  • isset($aggregatedData[$client][$week]): 检查这个客户的这一周是不是第一次记录。不是就不用操作,是的话,给这个客户的这一周初始化一个值为 0。这一步很重要,确保了后续的 += 操作能正常进行。
  • $aggregatedData[$client][$week] += $amount;: 核心的累加操作。把当前记录的 amount 加到对应客户对应周的总数上。

执行完这一步,$aggregatedData 就包含了每个客户在有记录的那些周的总 amount

第三步:构建最终的输出结构

万事俱备,只欠东风。现在利用 $allWeeks$aggregatedData 来组装成目标格式。

<?php
// 接上一步的代码

// 4. 构建最终结果数组
$finalResult = [
    'weeks' => $allWeeks,
    'series' => []
];

// 遍历聚合后的数据(每个客户)
foreach ($aggregatedData as $clientName => $clientWeeklyData) {
    $clientSeriesData = []; // 用于存放该客户最终的 data 数组

    // 遍历所有周数 ($allWeeks),确保 data 数组按周数顺序排列
    foreach ($allWeeks as $weekNum) {
        // 检查该客户在当前周 ($weekNum) 是否有数据
        if (isset($clientWeeklyData[$weekNum])) {
            // 如果有数据,使用聚合后的值
            $clientSeriesData[] = $clientWeeklyData[$weekNum];
        } else {
            // 如果没有数据(即缺失周),填 0
            $clientSeriesData[] = 0.0; // 或者直接用 0
        }
    }

    // 构建该客户的条目,添加到 finalResult['series'] 中
    $finalResult['series'][] = [
        'name' => $clientName,
        'data' => $clientSeriesData
    ];
}

// 大功告成!打印最终结果
echo "\n最终结果 (Final Result):\n";
print_r($finalResult);

?>

代码解释:

  • 初始化 $finalResult,先把排好序的 $allWeeks 放进去。'series' 初始化为空数组。
  • 外层循环遍历 $aggregatedData,每次处理一个客户 ($clientName 和他的周数据 $clientWeeklyData)。
  • 为每个客户初始化一个 $clientSeriesData 空数组,这将是最终放在 'data' 键下的数组。
  • 关键:内层循环不是遍历 $clientWeeklyData 的键(客户有数据的周),而是遍历全局的 $allWeeks 数组。 这保证了每个客户的 data 数组长度都一样,并且顺序与 'weeks' 数组一致。
  • 在内层循环中,对当前的全局周数 $weekNum,检查它是否存在于当前客户的 $clientWeeklyData 中(用 isset($clientWeeklyData[$weekNum]))。
    • 如果存在,说明该客户在本周有记录,就把聚合后的值 $clientWeeklyData[$weekNum] 添加到 $clientSeriesData
    • 如果不存在,说明该客户在本周没有记录(缺失周),就添加 0.0 (或 0) 到 $clientSeriesData
  • 内层循环结束后,$clientSeriesData 就构建好了,包含了该客户按全局周顺序排列的数据(缺失处已补0)。
  • 最后,将 ['name' => $clientName, 'data' => $clientSeriesData] 这个结构添加到 $finalResult['series'] 数组中。

全部循环结束后,$finalResult 就是你想要的目标结构了。

剖析与进阶

代码详解回顾

整个过程可以概括为:

  1. 准备阶段 (Setup): 获取全局维度(周列表 allWeeks)和初始化存储空间 (aggregatedData)。
  2. 聚合阶段 (Aggregation): 遍历原始数据,按照客户和周进行分组,并累加数值。
  3. 格式化阶段 (Formatting): 遍历聚合结果,对照全局维度 (allWeeks),生成最终格式,并处理缺失值(补0)。

这种分步处理的方式,逻辑清晰,易于理解和调试。

性能考量

  • 时间复杂度:
    • 获取周数 (array_column, array_unique, sort): 大致 O(N log N) 或 O(N),取决于 sort 的实现和数据分布,其中 N 是原始数组 $hours 的记录数。
    • 聚合数据 (遍历 $hours): O(N)。
    • 构建最终结果 (遍历 $aggregatedData$allWeeks): O(M * K),其中 M 是独立客户数,K 是独立周数。
    • 总的来说,性能主要取决于原始数据量 N 和最终结果的维度 M * K。对于大多数 Web 应用场景,这种处理方式足够快。
  • 内存消耗: 需要额外的内存来存储 $allWeeks, $aggregatedData, 和 $finalResult。如果原始数据和客户/周的数量非常巨大,内存消耗可能需要关注。
  • 大数据场景: 如果处理的数据量达到百万甚至千万级别,直接在 PHP 里操作可能会非常慢且耗内存。这种情况下,通常建议:
    • 数据库层面聚合: 利用 SQL 的 GROUP BYSUM() 功能直接在数据库查询时就完成聚合,效率最高。
    • 分块处理: 如果无法使用数据库,可以考虑将大数组分块读入内存处理。

代码健壮性

目前的实现假设输入数据 $hours 的结构总是符合预期(包含 name, week, amount 键)。在实际应用中,最好加上一些检查:

// 在聚合循环内增加检查
foreach ($hours as $record) {
    // 基本检查:确保必须的键存在
    if (!isset($record['name']) || !isset($record['week']) || !isset($record['amount'])) {
        //可以选择记录错误、跳过该条记录或抛出异常
        // error_log("Invalid record structure found: " . print_r($record, true));
        continue; // 跳过这条格式不正确的数据
    }

    $client = $record['name'];
    $week = $record['week'];
    // 类型检查和转换,确 अमाउंट 保是数值
    if (!is_numeric($record['amount'])) {
       // error_log("Non-numeric amount found for client {$client}, week {$week}: " . $record['amount']);
       $amount = 0.0; // 或者跳过这条记录 continue;
    } else {
       $amount = (float)$record['amount'];
    }


    // ... 后续聚合逻辑不变 ...
}

这样能让代码在遇到不规范数据时,行为更可控,不容易因为 Undefined index 或类型错误而中断。

可复用性:封装成函数

如果这个转换逻辑可能在多处使用,或者你想让它更通用,可以把它封装成一个函数:

<?php

/**
 * 将包含客户、周、数量的二维数组按客户分组聚合周数据。
 *
 * @param array $data 输入的二维数组,每个元素需包含 $nameKey, $weekKey, $valueKey 指定的键。
 * @param string $nameKey 代表客户名称的键名。
 * @param string $weekKey 代表周数(或其他分组依据)的键名。
 * @param string $valueKey 代表需要聚合的数值的键名。
 * @return array 转换后的数组,格式为 ['weeks' => [...], 'series' => [['name' => ..., 'data' => [...]], ...]]
 */
function groupAndAggregateData(array $data, string $nameKey = 'name', string $weekKey = 'week', string $valueKey = 'amount'): array
{
    if (empty($data)) {
        return ['weeks' => [], 'series' => []];
    }

    // 1. 提取所有周数并排序
    $allWeeks = array_column($data, $weekKey);
    if (!$allWeeks) { // 可能输入数据 $data 格式有问题,或 $weekKey 不对
        // 根据需要处理错误,这里返回空结构
         return ['weeks' => [], 'series' => []];
    }
    $allWeeks = array_unique($allWeeks);
    sort($allWeeks);

    // 2. 初始化并进行聚合
    $aggregatedData = [];
    foreach ($data as $record) {
        // 基础校验
        if (!isset($record[$nameKey]) || !isset($record[$weekKey]) || !isset($record[$valueKey])) {
            // 可选:记录日志或抛出异常
            continue;
        }

        $client = $record[$nameKey];
        $week = $record[$weekKey];
        $amount = $record[$valueKey];

        // 确保 amount 是数值
        if (!is_numeric($amount)) {
            // 可选:记录日志或将值视为 0
            $amount = 0;
        } else {
            $amount = (float)$amount;
        }


        if (!isset($aggregatedData[$client])) {
            $aggregatedData[$client] = [];
        }
        if (!isset($aggregatedData[$client][$week])) {
            $aggregatedData[$client][$week] = 0;
        }
        $aggregatedData[$client][$week] += $amount;
    }

    // 3. 构建最终结果
    $finalResult = [
        'weeks' => $allWeeks,
        'series' => []
    ];
    foreach ($aggregatedData as $clientName => $clientWeeklyData) {
        $clientSeriesData = [];
        foreach ($allWeeks as $weekNum) {
            $clientSeriesData[] = $clientWeeklyData[$weekNum] ?? 0.0; // 使用 null 合并运算符简化代码
        }
        $finalResult['series'][] = [
            'name' => $clientName,
            'data' => $clientSeriesData
        ];
    }

    return $finalResult;
}

// 使用示例:
$hours = [
    ['amount' => 75.00, 'name' => 'CLIENT1', 'week' => 22],
    ['amount' => 945.00, 'name' => 'CLIENT1', 'week' => 23],
    ['amount' => 45.00, 'name' => 'CLIENT1', 'week' => 24],
    ['amount' => 45.00, 'name' => 'CLIENTX', 'week' => 22],
    ['amount' => 15.00, 'name' => 'CLIENTX', 'week' => 22],
    ['amount' => 73.00, 'name' => 'CLIENTX', 'week' => 24],
];

$transformedData = groupAndAggregateData($hours); // 使用默认键名
// $transformedData = groupAndAggregateData($otherData, 'customer_id', 'month', 'sales_value'); // 使用自定义键名

echo "通过函数转换后的结果:\n";
print_r($transformedData);

?>

函数改进点:

  • 增加了对输入数组 $data 为空的检查。
  • 将键名 ('name', 'week', 'amount') 参数化,提高了函数的通用性。
  • 增加了对记录和数值类型的基本校验。
  • 在构建 $clientSeriesData 时使用了 PHP 7.0+ 的 null 合并运算符 (??) $clientWeeklyData[$weekNum] ?? 0.0,这是一种更简洁的写法,等价于 isset($clientWeeklyData[$weekNum]) ? $clientWeeklyData[$weekNum] : 0.0

这个函数就比较健壮和灵活了,可以直接在你的项目里调用。

处理数组转换问题时,先理清目标结构,然后分步骤解决关键点(如提取维度、聚合、处理缺失值),往往能让思路更清晰,代码也更可靠。