【问题标题】:Efficiently merge large object datasets having mulitple matching keys高效合并具有多个匹配键的大型对象数据集
【发布时间】:2018-12-06 16:21:44
【问题描述】:

在 Powershell 脚本中,我有两个具有多列的数据集。并非所有这些列都是共享的。

例如数据集1:

A B    XY   ZY  
- -    --   --  
1 val1 foo1 bar1
2 val2 foo2 bar2
3 val3 foo3 bar3
4 val4 foo4 bar4
5 val5 foo5 bar5
6 val6 foo6 bar6

和数据集 2:

A B    ABC  GH  
- -    ---  --  
3 val3 foo3 bar3
4 val4 foo4 bar4
5 val5 foo5 bar5
6 val6 foo6 bar6
7 val7 foo7 bar7
8 val8 foo8 bar8

我想合并这两个数据集,指定哪些列作为键(在我的简单案例中为 A 和 B)。预期结果是:

A B    XY   ZY   ABC  GH  
- -    --   --   ---  --  
1 val1 foo1 bar1          
2 val2 foo2 bar2          
3 val3 foo3 bar3 foo3 bar3
4 val4 foo4 bar4 foo4 bar4
5 val5 foo5 bar5 foo5 bar5
6 val6 foo6 bar6 foo6 bar6
7 val7           foo7 bar7
8 val8           foo8 bar8

这个概念与 SQL 交叉连接查询非常相似。

我已经能够成功编写一个合并对象的函数。不幸的是,计算的持续时间是指数级的。

如果我使用以下方法生成数据集:

$dsLength = 10
$dataset1 = 0..$dsLength | %{
    New-Object psobject -Property @{ A=$_ ; B="val$_" ; XY = "foo$_"; ZY ="bar$_" }
}
$dataset2 = ($dsLength/2)..($dsLength*1.5) | %{
    New-Object psobject -Property @{ A=$_ ; B="val$_" ; ABC = "foo$_"; GH ="bar$_" }
}

我得到了这些结果:

  • $dsLength = 10 ==> 33 毫秒(很好)
  • $dsLength = 100 ==> 89 毫秒(很好)
  • $dsLength = 1000 ==> 1563 毫秒(可接受)
  • $dsLength = 5000 ==> 35764 毫秒(太多)
  • $dsLength = 10000 ==> 138047 毫秒(太多)
  • $dsLength = 20000 ==> 573614 毫秒(太多了)

当数据集很大(我的目标是大约 20K 项)时,如何有效地合并数据集?

现在,我已经定义了这些函数:

function Merge-Objects{
    param(
        [Parameter(Mandatory=$true)]
        [object[]]$Dataset1,
        [Parameter(Mandatory=$true)]
        [object[]]$Dataset2,
        [Parameter()]
        [string[]]$Properties
    )

    $result = @()

    $ds1props = $Dataset1 | gm -MemberType Properties
    $ds2props = $Dataset2 | gm -MemberType Properties
    $ds1propsNotInDs2Props = $ds1props | ? { $_.Name -notin ($ds2props | Select -ExpandProperty Name) }
    $ds2propsNotInDs1Props = $ds2props | ? { $_.Name -notin ($ds1props | Select -ExpandProperty Name) }

    foreach($row1 in $Dataset1){
        $result += $row1
        $ds2propsNotInDs1Props | % {
            $row1 | Add-Member -MemberType $_.MemberType -Name $_.Name -Value $null
        }
    }

    foreach($row2 in $Dataset2){
        $existing = foreach($candidate in $result){
            $match = $true
            foreach($prop in $Properties){
                if(-not ($row2.$prop -eq $candidate.$prop)){
                    $match = $false                   
                    break                  
                }
            }
            if($match){
                $candidate
                break
            }
        }
        if(!$existing){
            $ds1propsNotInDs2Props | % {
                $row2 | Add-Member -MemberType $_.MemberType -Name $_.Name -Value $null
            }
            $result += $row2
        }else{
            $ds2propsNotInDs1Props | % {
                $existing.$($_.Name) = $row2.$($_.Name)
            }

        }
    }

    $result
}

我这样称呼这些函数:

Measure-Command -Expression {

    $data = Merge-Objects -Dataset1 $dataset1 -Dataset2 $dataset2 -Properties "A","B" 

}

我的感觉是缓慢是由于第二个循环,我尝试在每次迭代中匹配现有行

[编辑] 使用散列作为索引的第二种方法。令人惊讶的是,它的事件比第一次尝试慢

$dsLength = 1000
$dataset1 = 0..$dsLength | %{
    New-Object psobject -Property @{ A=$_ ; B="val$_" ; XY = "foo$_"; ZY ="bar$_" }
}
$dataset2 = ($dsLength/2)..($dsLength*1.5) | %{
    New-Object psobject -Property @{ A=$_ ; B="val$_" ; ABC = "foo$_"; GH ="bar$_" }
}

function Get-Hash{
    param(
        [Parameter(Mandatory=$true)]
        [object]$InputObject,
        [Parameter()]
        [string[]]$Properties    
    )

    $InputObject | Select-object $properties | Out-String
}


function Merge-Objects{
    param(
        [Parameter(Mandatory=$true)]
        [object[]]$Dataset1,
        [Parameter(Mandatory=$true)]
        [object[]]$Dataset2,
        [Parameter()]
        [string[]]$Properties
    )

    $result = @()
    $index = @{}

    $ds1props = $Dataset1 | gm -MemberType Properties
    $ds2props = $Dataset2 | gm -MemberType Properties
    $allProps = $ds1props + $ds2props | select -Unique

    $ds1propsNotInDs2Props = $ds1props | ? { $_.Name -notin ($ds2props | Select -ExpandProperty Name) }
    $ds2propsNotInDs1Props = $ds2props | ? { $_.Name -notin ($ds1props | Select -ExpandProperty Name) }

    $ds1index = @{}

    foreach($row1 in $Dataset1){
        $tempObject = new-object psobject
        $result += $tempObject
        $ds2propsNotInDs1Props | % {
            $tempObject | Add-Member -MemberType $_.MemberType -Name $_.Name -Value $null
        }
        $ds1props | % {
            $tempObject | Add-Member -MemberType $_.MemberType -Name $_.Name -Value $row1.$($_.Name)
        }

        $hash1 = Get-Hash -InputObject $row1 -Properties $Properties
        $ds1index.Add($hash1, $tempObject)

    }

    foreach($row2 in $Dataset2){
        $hash2 = Get-Hash -InputObject $row2 -Properties $Properties

        if($ds1index.ContainsKey($hash2)){
            # merge object
            $existing = $ds1index[$hash2]
            $ds2propsNotInDs1Props | % {
                $existing.$($_.Name) = $row2.$($_.Name)
            }
            $ds1index.Remove($hash2)

        }else{
            # add object
            $tempObject = new-object psobject
            $ds1propsNotInDs2Props | % {
                $tempObject | Add-Member -MemberType $_.MemberType -Name $_.Name -Value $null
            }
            $ds2props | % {
                $tempObject | Add-Member -MemberType $_.MemberType -Name $_.Name -Value $row2.$($_.Name)
            }
            $result += $tempObject
        }
    }

    $result
}

Measure-Command -Expression {

    $data = Merge-Objects -Dataset1 $dataset1 -Dataset2 $dataset2 -Properties "A","B" 

}

[Edit2] 在两个循环周围放置 Measure-Commands 表明事件第一个循环仍然很慢。实际上第一个循环占用了总时间的 50% 以上

【问题讨论】:

  • 我会建议在组合对象时使用哈希表。看看Join-Object
  • A 和 B 是否都是唯一键,或者 A 或 B 都可以使用?在您的示例中,它们是相同的。你的两个对象中可以有“1 val1”和“1 val1prime”吗?
  • @KoryGill:A 和 B 组成了密钥。两者都必须匹配
  • 有趣 - 我假设您的意思是即使使用大型数据集(例如 20k 行)它也会变慢?

标签: performance powershell loops


【解决方案1】:

我同意@Matt。使用哈希表——如下所示。这应该在m + 2n 而不是mn 时间运行。

我的系统上的时间

上面的原始解决方案

#10    TotalSeconds      :   0.07788
#100   TotalSeconds      :   0.37937
#1000  TotalSeconds      :   5.25092
#10000 TotalSeconds      : 242.82018
#20000 TotalSeconds      : 906.01584

这看起来肯定是 O(n^2)

下面的解决方案

#10    TotalSeconds      :  0.094
#100   TotalSeconds      :  0.425
#1000  TotalSeconds      :  3.757
#10000 TotalSeconds      : 45.652
#20000 TotalSeconds      : 92.918

这看起来是线性的。

解决方案

我使用了三种技术来提高速度:

  1. 切换到哈希表。这允许恒定时间查找,因此您不必有嵌套循环。这是从 O(n^2) 到线性时间真正需要的唯一变化。它的缺点是需要完成更多的设置工作。因此,在循环计数足够大以支付设置费用之前,不会看到线性时间的优势。
  2. 使用 ArrayList 而不是原生数组。向本机数组添加项需要重新分配数组并复制所有项。所以这也是一个 O(n^2) 操作。由于此操作是在引擎级别完成的,因此该常数非常小,因此直到很久以后才会真正产生影响。
  3. 使用 PsObject.Copy 创建新对象。与其他两个相比,这是一个小的优化,但它对我来说将运行时间缩短了一半。

--

function Get-Hash{
    param(
        [Parameter(Mandatory=$true)]
        [object]$InputObject,
        [Parameter()]
        [string[]]$Properties    
    )

    $arr = [System.Collections.ArrayList]::new()

    foreach($p in $Properties) { $arr += $InputObject.$($p) }

    return ( $arr -join ':' )
}

function Merge-Objects{
    param(
        [Parameter(Mandatory=$true)]
        [object[]]$Dataset1,
        [Parameter(Mandatory=$true)]
        [object[]]$Dataset2,
        [Parameter()]
        [string[]]$Properties
    )

    $results = [System.Collections.ArrayList]::new()

    $ds1props = $Dataset1 | gm -MemberType Properties
    $ds2props = $Dataset2 | gm -MemberType Properties
    $ds1propsNotInDs2Props = $ds1props | ? { $_.Name -notin ($ds2props | Select -ExpandProperty Name) }
    $ds2propsNotInDs1Props = $ds2props | ? { $_.Name -notin ($ds1props | Select -ExpandProperty Name) }


    $hash = @{}
    $Dataset2 | % { $hash.Add( (Get-Hash $_ $Properties), $_) }

    foreach ($row in $dataset1) {

        $key = Get-Hash $row $Properties

        $tempObject = $row.PSObject.Copy()

        if ($hash.containskey($key)) {
            $r2 = $hash[$key]

            $hash.remove($key)
            $ds2propsNotInDs1Props | % {
                $tempObject | Add-Member -MemberType $_.MemberType -Name $_.Name -Value $r2.$($_.Name)
            }

        } else {
            $ds2propsNotInDs1Props | % {
                $tempObject | Add-Member -MemberType $_.MemberType -Name $_.Name -Value $null
            }
        }
        [void]$results.Add($tempObject)
    }

    foreach ($row in $hash.values ) {
        # add missing dataset2 objects and extend
        $tempObject = $row.PSObject.Copy()

        $ds1propsNotInDs2Props | % {
            $tempObject | Add-Member -MemberType $_.MemberType -Name $_.Name -Value $null
        }

        [void]$results.Add($tempObject)
    }

    $results
}

########

$dsLength = 10000
$dataset1 = 0..$dsLength | %{
    New-Object psobject -Property @{ A=$_ ; B="val$_" ; XY = "foo$_"; ZY ="bar$_" }
}
$dataset2 = ($dsLength/2)..($dsLength*1.5) | %{
    New-Object psobject -Property @{ A=$_ ; B="val$_" ; ABC = "foo$_"; GH ="bar$_" }
}

Measure-Command -Expression {

    $data = Merge-Objects -Dataset1 $dataset1 -Dataset2 $dataset2 -Properties "A","B" 

}

【讨论】:

  • 感谢您的回答。只需更改哈希生成,因为 Select-Object -join 不返回字符串。我使用$InputObject | Select-object $properties | Out-String 来确保输出是一个字符串。不幸的是,该方法比第一次尝试慢(问题已更新以反映尝试)
  • @SteveB - 我很高兴它有帮助 - 但你为什么继续使用对象而不仅仅是一个哈希表?我不知道你买的是什么?并且所有 cmdlet 调用而不仅仅是哈希访问正在消耗时间。
  • 我不明白你的意思。我为每个合并的行使用对象。我的实际数据是来自 3rd 方库的已关闭业务对象。如果我不构建新对象,它会更改输入对象(因为它是引用,而不是复制)
  • 对不起,你是对的。我浏览你的新代码太快了。
【解决方案2】:

对于我将binary search(哈希表)合并到我的Join-Object cmdlet(另请参阅:In Powershell, what's the best way to join two tables into one?)中存在很多疑问,因为有一些问题需要克服,这些问题很容易被忽略来自问题中的示例。

很遗憾,我无法与@mhhollomon 解决方案的性能竞争:

dsLength Steve1 Steve2 mhhollomon Join-Object
-------- ------ ------ ---------- -----------
      10     19    129         21          50
     100    145    915        158         329
    1000   2936   9646       1575        3355
    5000  56129  69558       5814       12653
   10000 183813  95472      14740       25730
   20000 761450 265061      36822       80644

但我认为我可以增加一些价值:

不对

哈希键是字符串,这意味着你需要将相关属性强制转换为字符串,这有点值得怀疑,因为:

$Left -eq $Right ≠ "$Left" -eq "$Right"

在大多数情况下它会起作用,尤其是当源是 .csv 文件时,但它可能会出错,例如如果数据来自 cmdlet,其中$Null 确实意味着其他内容,则为空字符串 ('')。因此,我建议明确定义 $Null 键,例如带有Control character
由于属性值很容易包含冒号 (:),因此我还建议使用控制字符来分隔(连接)多个键。

也对

使用哈希表还有另一个陷阱,这实际上并不一定是问题:如果左侧 ($dataset1) 和/或右侧 ($dataset2) 有多个匹配项怎么办。举个例子以下数据集:

$dataset1 =ConvertFrom-SourceTable'

    A B    XY    ZY  
    - -    --    --  
    1 val1 foo1  bar1
    2 val2 foo2  bar2
    3 val3 foo3  bar3
    4 val4 foo4  bar4
    4 val4 foo4a bar4a
    5 val5 foo5  bar5
    6 val6 foo6  bar6
'

$dataset2 =ConvertFrom-SourceTable'

    A B    ABC   GH  
    - -    ---   --  
    3 val3 foo3  bar3
    4 val4 foo4  bar4
    5 val5 foo5  bar5
    5 val5 foo5a bar5a
    6 val6 foo6  bar6
    7 val7 foo7  bar7
    8 val8 foo8  bar8
'

在这种情况下,我希望 SQL 连接和没有 Item has already been added. Key in dictionary 错误的结果类似:

$Dataset1 | FullJoin $dataset2 -On A, B | Format-Table

A B    XY    ZY    ABC   GH
- -    --    --    ---   --
1 val1 foo1  bar1
2 val2 foo2  bar2
3 val3 foo3  bar3  foo3  bar3
4 val4 foo4  bar4  foo4  bar4
4 val4 foo4a bar4a foo4  bar4
5 val5 foo5  bar5  foo5  bar5
5 val5 foo5  bar5  foo5a bar5a
6 val6 foo6  bar6  foo6  bar6
7 val7             foo7  bar7
8 val8             foo8  bar8

只对了

您可能已经知道,没有理由将两边都放在哈希表中,但您可以考虑左侧(而不是阻塞输入)。在问题的示例中,两个数据集都直接加载到内存中,这几乎不是一个用例。更常见的是您的数据来自其他地方,例如如果您可以在下一个对象进入之前同时搜索哈希表中的每个传入对象,那么您可以从活动目录远程访问。以下 cmdlet 也同样重要:它可能会直接开始处理输出,而不必等到您的cmdlet 已完成(请注意,当Join-Object cmdlet 准备好时,数据会立即释放)。在这种情况下,使用 Measure-Command 测量性能需要完全不同的方法...
另见:Computer Programming: Is the PowerShell pipeline sequential mode more memory efficient? Why or why not?

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 2019-09-28
    • 2022-11-24
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多