将List<Map<String, List<String>>>转换为String[][]

6
我有一个使用场景,在那里我会爬取一些数据,对于某些记录,一些键具有多个值。我最终想要的输出是CSV格式,我有一个库可以做到这一点,并且它需要一个二维数组。
所以我的输入结构看起来像是List<TreeMap<String, List<String>>>(我使用TreeMap确保键的顺序稳定),我的输出需要是String[][]
我编写了一个通用的转换,它基于所有记录中的最大值数量计算每个键的列数,并为具有小于最大值的记录留下空单元格,但结果比预期更复杂。
我的问题是:它能否以更简洁/有效(但仍然通用)的方式编写?特别是使用Java 8流/lambda等?
以下是示例数据和我的算法(尚未测试超出示例数据):
package org.example.import;

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

public class Main {

    public static void main(String[] args) {
        List<TreeMap<String, List<String>>> rows = new ArrayList<>();
        TreeMap<String, List<String>> row1 = new TreeMap<>();
        row1.put("Title", Arrays.asList("Product 1"));
        row1.put("Category", Arrays.asList("Wireless", "Sensor"));
        row1.put("Price",Arrays.asList("20"));
        rows.add(row1);
        TreeMap<String, List<String>> row2 = new TreeMap<>();
        row2.put("Title", Arrays.asList("Product 2"));
        row2.put("Category", Arrays.asList("Sensor"));
        row2.put("Price",Arrays.asList("35"));
        rows.add(row2);
        TreeMap<String, List<String>> row3 = new TreeMap<>();
        row3.put("Title", Arrays.asList("Product 3"));
        row3.put("Price",Arrays.asList("15"));
        rows.add(row3);

        System.out.println("Input:");
        System.out.println(rows);
        System.out.println("Output:");
        System.out.println(Arrays.deepToString(multiValueListsToArray(rows)));
    }

    public static String[][] multiValueListsToArray(List<TreeMap<String, List<String>>> rows)
    {
        Map<String, IntSummaryStatistics> colWidths = rows.
                stream().
                flatMap(m -> m.entrySet().stream()).
                collect(Collectors.groupingBy(e -> e.getKey(), Collectors.summarizingInt(e -> e.getValue().size())));
        Long tableWidth = colWidths.values().stream().mapToLong(IntSummaryStatistics::getMax).sum();
        String[][] array = new String[rows.size()][tableWidth.intValue()];
        Iterator<TreeMap<String, List<String>>> rowIt = rows.iterator(); // iterate rows
        int rowIdx = 0;
        while (rowIt.hasNext())
        {
            TreeMap<String, List<String>> row = rowIt.next();
            Iterator<String> colIt = colWidths.keySet().iterator(); // iterate columns
            int cellIdx = 0;
            while (colIt.hasNext())
            {
                String col = colIt.next();
                long colWidth = colWidths.get(col).getMax();
                for (int i = 0; i < colWidth; i++) // iterate cells within column
                    if (row.containsKey(col) && row.get(col).size() > i)
                        array[rowIdx][cellIdx + i] = row.get(col).get(i);
                cellIdx += colWidth;
            }
            rowIdx++;
        }
        return array;
    }

}

程序输出:

Input:
[{Category=[Wireless, Sensor], Price=[20], Title=[Product 1]}, {Category=[Sensor], Price=[35], Title=[Product 2]}, {Price=[15], Title=[Product 3]}]
Output:
[[Wireless, Sensor, 20, Product 1], [Sensor, null, 35, Product 2], [null, null, 15, Product 3]]

4
如果你的代码正确且没有不必要的性能问题(我承认我没有彻底地阅读它),那么保持原样可能更好,虽然可能可以用更简洁的方式写出来,但我感觉并不会更短或更易读。 - Thomas
我可以问一下吗?你为什么要转换成 String[][] - MC Emperor
@MCEmperor因为CSV是一种表格式,所以CSV writer接受Object[][] - Martynas Jusevičius
我忘了添加的一件事是打印标题行,但这不应该很难。 - Martynas Jusevičius
2个回答

7
作为第一步,我不会关注新的Java 8功能,而是关注Java 5+的功能。当您可以使用for-each时,请勿处理Iterator。通常,不要遍历keySet()以执行每个键的映射查找,因为您可以遍历entrySet()而不需要任何查找。此外,当您只对最大值感兴趣时,请勿请求IntSummaryStatistics。并且不要遍历两个数据结构中更大的那个,只需在每次迭代中重新检查您是否超出较小的那个即可。
Map<String, Integer> colWidths = rows.
        stream().
        flatMap(m -> m.entrySet().stream()).
        collect(Collectors.toMap(e -> e.getKey(), e -> e.getValue().size(), Integer::max));
int tableWidth = colWidths.values().stream().mapToInt(Integer::intValue).sum();
String[][] array = new String[rows.size()][tableWidth];

int rowIdx = 0;
for(TreeMap<String, List<String>> row: rows) {
    int cellIdx = 0;
    for(Map.Entry<String,Integer> e: colWidths.entrySet()) {
        String col = e.getKey();
        List<String> cells = row.get(col);
        int index = cellIdx;
        if(cells != null) for(String s: cells) array[rowIdx][index++]=s;
        cellIdx += colWidths.get(col);
    }
    rowIdx++;
}
return array;

我们可以通过使用映射到列的 位置 而不是宽度来进一步简化循环:
Map<String, Integer> colPositions = rows.
        stream().
        flatMap(m -> m.entrySet().stream()).
        collect(Collectors.toMap(e -> e.getKey(),
                                 e -> e.getValue().size(), Integer::max, TreeMap::new));
int tableWidth = 0;
for(Map.Entry<String,Integer> e: colPositions.entrySet())
    tableWidth += e.setValue(tableWidth);

String[][] array = new String[rows.size()][tableWidth];

int rowIdx = 0;
for(Map<String, List<String>> row: rows) {
    for(Map.Entry<String,List<String>> e: row.entrySet()) {
        int index = colPositions.get(e.getKey());
        for(String s: e.getValue()) array[rowIdx][index++]=s;
    }
    rowIdx++;
}
return array;

可以通过以下更改在头部数组中添加内容:

Map<String, Integer> colPositions = rows.stream()
    .flatMap(m -> m.entrySet().stream())
    .collect(Collectors.toMap(e -> e.getKey(), e -> e.getValue().size(),
                              Integer::max, TreeMap::new));
String[] header = colPositions.entrySet().stream()
    .flatMap(e -> Collections.nCopies(e.getValue(), e.getKey()).stream())
    .toArray(String[]::new);
int tableWidth = 0;
for(Map.Entry<String,Integer> e: colPositions.entrySet())
    tableWidth += e.setValue(tableWidth);

String[][] array = new String[rows.size()+1][tableWidth];
array[0] = header;

int rowIdx = 1;
for(Map<String, List<String>> row: rows) {
    for(Map.Entry<String,List<String>> e: row.entrySet()) {
        int index = colPositions.get(e.getKey());
        for(String s: e.getValue()) array[rowIdx][index++]=s;
    }
    rowIdx++;
}
return array;

太好了!短得多,更加简洁。谢谢你。 - Martynas Jusevičius
您能否更新一个也打印标题的版本呢? :) 对于每一列。 - Martynas Jusevičius
我猜标题应该是映射键?如何处理与单个键相关联的多列?null、重复的键或在键中添加数字? - Holger
是的,映射键。我只会为每一列打印重复的键。 这可以看作是一种图形展开技术,其中键是项目属性,因此每个值应该具有相同的属性。 - Martynas Jusevičius
如果方法接受的参数是List<Map<String, List<String>>>而不是List<TreeMap<String, List<String>>>,在行循环内部构造new TreeMap<>(row)是否足够? - Martynas Jusevičius
1
输出顺序完全由colPositions映射确定,无论输入是否为TreeMap。因此,您可以将输入更改为List<Map<String,List<String>>>,甚至不需要在循环中执行类似于new TreeMap<>(row)的操作。要获得保证排序的列顺序,您所要做的就是在上面的解决方案中将HashMap :: new更改为TreeMap :: new,即使输入映射是树形映射,也必须这样做。使用HashMap,如果当前测试数据在输出中看起来已排序,则纯属巧合。我已相应地更新了答案。 - Holger

1
这是一种使用功能进行简洁处理的方法。
此解决方案假定仅类别数据是动态的,而您始终只有一个价格和一个产品名称。
考虑到您拥有初始数据。
// your initial complex data list 
List<Map<String, List<String>>> initialList = new ArrayList<>();

你可以做
// values holder before final conversion
final List<List<String>> tempValues = new ArrayList<>();
initialList.forEach( map -> {
    // discard the keys, we do not need them... so only pack the data and put in a temporary array
    tempValues.add(new ArrayList<String>() {{
        map.forEach((key, value) -> addAll(value));          // foreach (string, list) : Map<String, List<String>>
    }});
});
// get the biggest data list; in our case, the one that contains most categories...
// this is going to be the final data size
final int maxSize = tempValues.stream().max(Comparator.comparingInt(List::size)).get().size();
// now we finally know the data size
final String[][] finalValues = new String[initialList.size()][maxSize];
// now it's time to uniform the bundle data size and shift the elements if necessary

// can't use streams/lambda as I need to keep an iteration counter
for (int i = 0; i < tempValues.size(); i++) {
    final List<String> tempEntry = tempValues.get(i);
    if (tempEntry.size() == maxSize) {
        finalValues[i] = tempEntry.toArray(finalValues[i]);
        continue;
    }
    final String[] s = new String[maxSize];
    // same shifting game as before
    final int delta = maxSize - tempEntry.size();
    for (int j = 0; j < maxSize; j++) {
        if (j < delta) continue;
        s[j] = tempEntry.get(j - delta);
    }
    finalValues[i] = s;
}

就是这样了...


您可以使用以下方法填充和测试数据(我添加了一些更多的类别...)
static void initData(List<Map<String, List<String>>> l) {
    l.add(new TreeMap<String, List<String>>() {{
        put("Category", new ArrayList<String>() {{ add("Wireless"); add("Sensor"); }});
        put("Price", new ArrayList<String>() {{ add("20"); }});
        put("Title", new ArrayList<String>() {{ add("Product 1"); }});
    }});
    l.add(new TreeMap<String, List<String>>() {{
        put("Category", new ArrayList<String>() {{ add("Sensor"); }});
        put("Price", new ArrayList<String>() {{ add("35"); }});
        put("Title", new ArrayList<String>() {{ add("Product 2"); }});
    }});
    l.add(new TreeMap<String, List<String>>() {{
        put("Price", new ArrayList<String>() {{ add("15"); }});
        put("Title", new ArrayList<String>() {{ add("Product 3"); }});
    }});
    l.add(new TreeMap<String, List<String>>() {{
        put("Category", new ArrayList<String>() {{ add("Wireless"); add("Sensor"); add("Category14"); }});
        put("Price", new ArrayList<String>() {{ add("15"); }});
        put("Title", new ArrayList<String>() {{ add("Product 3"); }});
    }});
    l.add(new TreeMap<String, List<String>>() {{
        put("Category", new ArrayList<String>() {{ add("Wireless"); add("Sensor"); add("Category541"); add("SomeCategory");}});
        put("Price", new ArrayList<String>() {{ add("15"); }});
        put("Title", new ArrayList<String>() {{ add("Product 3"); }});
    }});
}

我仍然认为,被接受的答案看起来计算成本较低,但你想看一些Java 8的内容...


FINAL_DATA_BUNDLE_SIZE 需要事先设置,而不是动态计算? - Martynas Jusevičius
@MartynasJusevičius 噢,抱歉,我会尽快更新。 - payloc91
@MartynasJusevičius做到了。 - payloc91
4
你知道你的代码创建了多少个类吗?与使用Arrays.asList(…)相比,使用双括号反模式甚至不能使代码更简洁。 - Holger
1
我并不认为那是“非常强大的工具”。你的initData创建了19个类,所有的ArrayList子类都存储了对其外部TreeMap实例的意外引用,而整个代码与问题中提供的OP代码相比更加庞大且难以阅读。甚至没有必要重写代码,只需复制即可。由于我没有重写该代码,因此在我的一方不存在“极其昂贵”的变体。 - Holger
显示剩余2条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接