data.table 教程3-主键、基于二分法搜索的subset

目录:

  1. data.table 介绍
  2. 语义引用
  3. 主键、基于二分法搜索的subset
  4. 二次索引和自动索引
  5. 数据拆分和合并

原文地址:
data.table/wiki/Getting-started


本教程主要提供给那些已经熟悉data.table的语法、懂得subset行select列、知道如何添加/更新/删除列的人员学习。如果你对这些不熟悉,请学习上面两讲 data.table 介绍语义引用


数据

我们继续使用上一讲中使用的航班信息flights。

flights <- fread("https://raw.githubusercontent.com/wiki/arunsrinivasan/    flights/NYCflights14/flights14.csv")
flights
#         year month day dep_time dep_delay arr_time arr_delay cancelled carrier tailnum flight
#      1: 2014     1   1      914        14     1238        13         0      AA  N338AA      1
#      2: 2014     1   1     1157        -3     1523        13         0      AA  N335AA      3
#      3: 2014     1   1     1902         2     2224         9         0      AA  N327AA     21
#      4: 2014     1   1      722        -8     1014       -26         0      AA  N3EHAA     29
#      5: 2014     1   1     1347         2     1706         1         0      AA  N319AA    117
#     ---                                                                                      
# 253312: 2014    10  31     1459         1     1747       -30         0      UA  N23708   1744
# 253313: 2014    10  31      854        -5     1147       -14         0      UA  N33132   1758
# 253314: 2014    10  31     1102        -8     1311        16         0      MQ  N827MQ   3591
# 253315: 2014    10  31     1106        -4     1325        15         0      MQ  N511MQ   3592
# 253316: 2014    10  31      824        -5     1045         1         0      MQ  N813MQ   3599
#         origin dest air_time distance hour min
#      1:    JFK  LAX      359     2475    9  14
#      2:    JFK  LAX      363     2475   11  57
#      3:    JFK  LAX      351     2475   19   2
#      4:    LGA  PBI      157     1035    7  22
#      5:    JFK  LAX      350     2475   13  47
#     ---                                       
# 253312:    LGA  IAH      201     1416   14  59
# 253313:    EWR  IAH      189     1400    8  54
# 253314:    LGA  RDU       83      431   11   2
# 253315:    LGA  DTW       75      502   11   6
# 253316:    LGA  SDF      110      659    8  24
dim(flights)
# [1] 253316     17

介绍

在这一讲,我们会:

* 介绍“主键”的概念,在参数i里面,设置并使用主键进行基于快速二分法搜索的subset。
* 学习如何将基于主键的subset,与参数i和by相结合,就像以前做的一样。
* 学习另外两个有用的参数 mult 和 nomatch
* 最后总结一下主键的优越性:基于快速二分法搜索的subset的表现,并和传统的vector scan approach对比。

1.主键

a) 什么是主键

data.table 介绍里,我们学习了如何在参数i里指定逻辑表达式和行号subset行,以及如何使用 order().在这一讲,我们会学习如何使用主键subset行,而且这难以置信的快。
但首先,我们从data.frame开始。所有的data.frame都有一个行名的属性。看下面这个data.frame DF。

set.seed(1L)
DF = data.frame(ID1 = sample(letters[1:2], 10, TRUE), 
                ID2 = sample(1:3, 10, TRUE),
                val = sample(10), 
                stringsAsFactors = FALSE,
                row.names = sample(LETTERS[1:10]))
DF
#   ID1 ID2 val
# C   a   3   5
# D   a   1   6
# E   b   2   4
# G   a   1   2
# B   b   1  10
# H   a   2   8
# I   b   1   9
# F   b   2   1
# J   a   3   7
# A   b   2   3

rownames(DF)
#  [1] "C" "D" "E" "G" "B" "H" "I" "F" "J" "A"

我们可以用行名来subset一行,就像这样:

DF["C", ]
#   ID1 ID2 val
# C   a   3   5

行名,或多或少,算是一个data.frame的索引。然而,

  1. 每行都有且只有一个行名。
    但是,一个人可能有两个名字,比如名字和中间名。当编纂电话簿的时候,这就非常有用。
  2. 而且行名必须是独一无二的。

    rownames(DF) = sample(LETTERS[1:5], 10, TRUE)

    Warning: non-unique values when setting ‘row.names’: ‘C’, ‘D’

    Error in row.names<-.data.frame(*tmp*, value = value): duplicate ‘row.names’ are not allowed

下面我们来看看data.table吧。

DT = as.data.table(DF)
DT
#     ID1 ID2 val
#  1:   a   3   5
#  2:   a   1   6
#  3:   b   2   4
#  4:   a   1   2
#  5:   b   1  10
#  6:   a   2   8
#  7:   b   1   9
#  8:   b   2   1
#  9:   a   3   7
# 10:   b   2   3

rownames(DT)
#  [1] "1"  "2"  "3"  "4"  "5"  "6"  "7"  "8"  "9"  "10"

注意:

* 行名被重置了。
* data.table从来不使用行名。既然data.table集成了data.frame,那它还是有行名这个属性的,但是从来不使用。马上我们就知道为什么了。
如果想保持行名,在调用 as.data.table()的时候指定 keep.rownames = TRUE,这回创建一个叫做 rn的列,并且将列名赋值给这一列。

而在data.table里,我们使用主键。主键是更有效的行名。

主键及其特性
* 我们可以对多个列设置主键,这些列可能是不同的类型-integer, numeric, character, factor, integer64等等。但还不支持list和complex。
* 不强制唯一性,也就是说,不同列的主键可以是一样的。既然行可以通过主键排序,那么排序的时候,具有同样主键的一些行,会被排在一起。
* 设置主键这个过程分两步:
  a.根据指定的列,对data.table重新排序,而且总是按升序排列。
  b.对于data.table,通过设置一个叫做 sorted 的属性,来把那些列标记为主键列。
  既然是排序,一个data.table最多只能有一个主键,因为它不能按照两种方法排序。

在教程接下来的部分,我们一直都是用航班信息 flights 来讲解。

b) 设置/获取/使用主键

-如何将 origin列设置为主键

setkey(flights, origin)
head(flights)
#    year month day dep_time dep_delay arr_time arr_delay cancelled carrier tailnum flight origin
# 1: 2014     1   1     1824         4     2145         0         0      AA  N3DEAA    119    EWR
# 2: 2014     1   1     1655        -5     2003       -17         0      AA  N5CFAA    172    EWR
# 3: 2014     1   1     1611       191     1910       185         0      AA  N471AA    300    EWR
# 4: 2014     1   1     1449        -1     1753        -2         0      AA  N4WNAA    320    EWR
# 5: 2014     1   1      607        -3      905       -10         0      AA  N5DMAA   1205    EWR
# 6: 2014     1   1      949         4     1243       -17         0      AA  N491AA   1223    EWR
#    dest air_time distance hour min
# 1:  LAX      339     2454   18  24
# 2:  MIA      161     1085   16  55
# 3:  DFW      214     1372   16  11
# 4:  DFW      214     1372   14  49
# 5:  MIA      154     1085    6   7
# 6:  DFW      215     1372    9  49

## alternatively we can provide character vectors to the function 'setkeyv()'
# setkeyv(flights, "origin") # useful to program with

说明:

* 你可以给函数setkey() 传入列名作为参数,不需要引号。这在交互式使用的时候特别方便。
* 换一种方式,你可以给函数setkeyv() 传一个字符型的向量,这个向量里保存的是列名。这在把列作为参数传给一个新创建的函数,来设置主键的时候特别方便。
* 注意,我们不需要将结果赋值给一个变量。这是因为,setkey() 和 setkeyv()可以直接更新输入的data.table,就和上一讲中的操作符":="一样。它们没有返回值。
* 现在这个data.table已经按照我们提供的 origin列重新排序了。虽然是重新排序,但我们只需要请求和data.table的行数等长的一列这么大的内存空间。你看,又节省内存开销了。
* 你也可以在创建data.table的时候,调用函数data.table() 的参数 key=,直接设置主键,参数key的值是列名的字符型向量。

注意:

set* and :=:
在data.table里,操作符":="和所有的以set开头函数(比如setkey,setorder,setname等)一样,它们都会更新输入的原数据。

一旦将某一列设置成data.table的主键,就可以在参数i里指定 .()来subset那些主键了。回忆一下,.()就是 list()的别名。

-使用主键origin 来subset所有origin是”JFK”的行

flights[.("JFK")]
#        year month day dep_time dep_delay arr_time arr_delay cancelled carrier tailnum flight origin
#     1: 2014     1   1      914        14     1238        13         0      AA  N338AA      1    JFK
#     2: 2014     1   1     1157        -3     1523        13         0      AA  N335AA      3    JFK
#     3: 2014     1   1     1902         2     2224         9         0      AA  N327AA     21    JFK
#     4: 2014     1   1     1347         2     1706         1         0      AA  N319AA    117    JFK
#     5: 2014     1   1     2133        -2       37       -18         0      AA  N323AA    185    JFK
#    ---                                                                                             
# 81479: 2014    10  31     1705        -4     2024       -21         0      UA  N596UA    512    JFK
# 81480: 2014    10  31     1827        -2     2133       -37         0      UA  N568UA    514    JFK
# 81481: 2014    10  31     1753         0     2039       -33         0      UA  N518UA    535    JFK
# 81482: 2014    10  31      924        -6     1228       -38         0      UA  N512UA    541    JFK
# 81483: 2014    10  31     1124        -6     1408       -38         0      UA  N590UA    703    JFK
#        dest air_time distance hour min
#     1:  LAX      359     2475    9  14
#     2:  LAX      363     2475   11  57
#     3:  LAX      351     2475   19   2
#     4:  LAX      350     2475   13  47
#     5:  LAX      338     2475   21  33
#    ---                                
# 81479:  SFO      337     2586   17   5
# 81480:  SFO      344     2586   18  27
# 81481:  LAX      320     2475   17  53
# 81482:  SFO      343     2586    9  24
# 81483:  LAX      323     2475   11  24

## alternatively
# flights[J("JFK")] (or) flights[list("JFK")]

说明:

* 因为已经将主键设置为 origin列了,所以只要直接指定"JFK"就可以了。这里 .()用来在data.table的主键(也就是flights 的 origin列)里,查找"JFK"。
* 首先,满足"JFK"条件的行的索引都被获取到。然后,这些行的哪些信息是必要的呢。既然参数j里没有指定任何表达式,这些行的所有列都被返回了。
* 如果主键是字符型的列,那么可以省略 .(),就像用行名subset一个data.frame的行的时候。
flights["JFK"]              ## same as flights[.("JFK")]

* 我们可以根据需要指定多个值
flights[c("JFK", "LGA")]    ## same as flights[.(c("JFK", "LGA"))]
这返回所有 origin列是“JFK” 或者 “LGA”的所有行。

-如何获得被设置为data.table的主键的那一列的列名
使用函数 key()。

key(flights)
# [1] "origin"

说明:

* 函数 key() 返回主键列名的字符型向量。
* 如果data.table没有设置过主键,返回 NULL。

c) 主键和多个列

主键是更有效的行名。我们可以将多个列设置为主键,它们可以是不同的类型。
-如何将 origin列 和 dest列 都设置为主键

setkey(flights, origin, dest)
head(flights)
#    year month day dep_time dep_delay arr_time arr_delay cancelled carrier tailnum flight origin
# 1: 2014     1   2      724        -2      810       -25         0      EV  N11547   4373    EWR
# 2: 2014     1   3     2313        88        9        79         0      EV  N18120   4470    EWR
# 3: 2014     1   4     1526       220     1618       211         0      EV  N11184   4373    EWR
# 4: 2014     1   4      755        35      848        19         0      EV  N14905   4551    EWR
# 5: 2014     1   5      817        47      921        42         0      EV  N19966   4470    EWR
# 6: 2014     1   5     2301        66        2        62         0      EV  N19966   4682    EWR
#    dest air_time distance hour min
# 1:  ALB       30      143    7  24
# 2:  ALB       29      143   23  13
# 3:  ALB       32      143   15  26
# 4:  ALB       32      143    7  55
# 5:  ALB       26      143    8  17
# 6:  ALB       31      143   23   1

## or alternatively
# setkeyv(flights, c("origin", "dest")) # provide a character vector of column names

key(flights)
# [1] "origin" "dest"

说明:

* data.table先按 origin列 排序,再按 dest列 排序。

-subset所有满足条件 origin是”JFK”、dest是”MIA”的行

flights[.("JFK", "MIA")]
#       year month day dep_time dep_delay arr_time arr_delay cancelled carrier tailnum flight origin
#    1: 2014     1   1     1509        -1     1828       -17         0      AA  N5FJAA    145    JFK
#    2: 2014     1   1      917         7     1227        -8         0      AA  N5DWAA   1085    JFK
#    3: 2014     1   1     1227         2     1534        -1         0      AA  N635AA   1697    JFK
#    4: 2014     1   1      546         6      853         3         0      AA  N5CGAA   2243    JFK
#    5: 2014     1   1     1736         6     2043       -12         0      AA  N397AA   2351    JFK
#   ---                                                                                             
# 2746: 2014    10  31     1659        -1     1956       -22         0      AA  N5FNAA   2351    JFK
# 2747: 2014    10  31      826        -3     1116       -20         0      AA  N5EYAA   1085    JFK
# 2748: 2014    10  31      647         2      941       -17         0      AA  N5BTAA   1101    JFK
# 2749: 2014    10  31      542        -3      834       -12         0      AA  N3ETAA   2299    JFK
# 2750: 2014    10  31     1944        29     2232         4         0      AA  N5FSAA   2387    JFK
#       dest air_time distance hour min
#    1:  MIA      161     1089   15   9
#     2:  MIA      166     1089    9  17
#    3:  MIA      164     1089   12  27
#    4:  MIA      157     1089    5  46
#    5:  MIA      154     1089   17  36
#   ---                                
# 2746:  MIA      148     1089   16  59
# 2747:  MIA      146     1089    8  26
# 2748:  MIA      150     1089    6  47
# 2749:  MIA      150     1089    5  42
# 2750:  MIA      146     1089   19  44

说明:

这里发生了什么事?
* 理解内部的处理步骤很重要。首先,用"JFK"和第一个主键 origin列匹配;然后,在匹配上的这些行里,用“MIA”和第二个主键 dest列匹配,这样来获取所有符合这两个条件的行的索引。
* 既然我们没有指定参数j,那就会返回所有符合上面索引的行。

-subset所有仅仅满足条件dest是”MIA”的行

flights[.(unique(origin), "MIA")]
#       year month day dep_time dep_delay arr_time arr_delay cancelled carrier tailnum flight origin
#    1: 2014     1   1     1655        -5     2003       -17         0      AA  N5CFAA    172    EWR
#    2: 2014     1   1      607        -3      905       -10         0      AA  N5DMAA   1205    EWR
#    3: 2014     1   1     1125        -5     1427        -8         0      AA  N3AGAA   1623    EWR
#    4: 2014     1   1     1533        43     1840        42         0      UA  N491UA    244    EWR
#    5: 2014     1   1     2130        60       29        49         0      UA  N476UA    308    EWR
#   ---                                                                                             
# 9924: 2014    10  31     1348       -11     1658        -8         0      AA  N3AMAA   2283    LGA
# 9925: 2014    10  31      950        -5     1257       -11         0      AA  N3LFAA   2287    LGA
# 9926: 2014    10  31      658        -2     1017        10         0      AA  N3HNAA   2451    LGA
# 9927: 2014    10  31     1913        -2     2212       -16         0      AA  N3LFAA   2455    LGA
# 9928: 2014    10  31     1530         1     1839       -11         0      US  N768US   1715    LGA
#       dest air_time distance hour min
#    1:  MIA      161     1085   16  55
#    2:  MIA      154     1085    6   7
#    3:  MIA      157     1085   11  25
#    4:  MIA      155     1085   15  33
#    5:  MIA      162     1085   21  30
#   ---                                
# 9924:  MIA      157     1096   13  48
# 9925:  MIA      150     1096    9  50
# 9926:  MIA      156     1096    6  58
# 9927:  MIA      156     1096   19  13
# 9928:  MIA      164     1096   15  30

说明:

这里发生了什么事?
* 回忆一下刚刚讲的处理步骤。首先,找到满足第一个主键origin列的条件的行;然后在这个结果中,找到满足第二个主键dest列是“MIA”的行。我们不能简单地事先跳过第一个主键列。因此,我们必须通过主键 origin列,获得它所有可能的取值。
* “MIA”会被自动补足成跟 unique(origin) 同样的长度,也就是3。

2. 和参数j、参数by一起使用

到目前为止,我们学习的都是同样的概念,也就是通过参数i取得行,只是使用了主键这种新的方法。那么同样的,我们在参数j和参数by里面使用主键,也没什么大惊小怪的。我们通过几个例子来说明。

a) 在参数j里面select

-返回符合 origin = “LGA” 和 dest = “TPA”这两个条件的 arr_delay列

key(flights)
# [1] "origin" "dest"
flights[.("LGA", "TPA"), .(arr_delay)]
#       arr_delay
#    1:         1
#    2:        14
#    3:       -17
#    4:        -4
#    5:       -12
#   ---          
# 1848:        39
# 1849:       -24
# 1850:       -12
# 1851:        21
# 1852:       -11

说明:

* 通过基于主键的subset,我们获得了满足 origin == "LGA" 和 dest == “TPA”这两个条件的行索引。
* 现在我们已经获得了这些行的索引,而参数j只请求了 arr_delay列。那么我们简单地从这些行索引中选取 arr_delay列,就像我们在第一讲中做的那样。
* 同以前一样,我们也可以指定 with = FALSE:
flights[.("LGA", "TPA"), "arr_delay", with=FALSE]

b) Chaining表达式

-在上面的基础上,将结果用chaining表达式按降序排列

flights[.("LGA", "TPA"), .(arr_delay)][order(-arr_delay)]
#       arr_delay
#    1:       486
#    2:       380
#    3:       351
#    4:       318
#    5:       300
#   ---          
# 1848:       -40
# 1849:       -43
# 1850:       -46
# 1851:       -48
# 1852:       -49

c) 在参数j里运算

-找出符合 origin = “LGA” 和 dest = “TPA”这两个条件的航班的最大到达延误时间

flights[.("LGA", "TPA"), max(arr_delay)]
# [1] 486

说明:

* 注意一下,这个结果(486),就是b)的结果的第一行的值。

d) 在参数j里使用操作符”:=”来sub-assign

我们已经在第二讲语义引用里学习了几个例子了。现在来看看filghts里的 hours列。

# get all 'hours' in flights
flights[, sort(unique(hour))]
#  [1]  0  1  2  3  4  5  6  7  8  9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24

可以看到,hour列有25种不同的取值。但是0点和24点应该是一样的,我们来把24点全部替换成0点。这次我们用主键来做。

setkey(flights, hour)
key(flights)
# [1] "hour"
flights[.(24), hour := 0L]
key(flights)
# NULL

说明:

* 我们首先将 hour列设置为主键。这会将flights按照 hour列重新排序,并且将 hour列标记为主键。
* 现在我们用 .()标记对hour列来subset。我们subset所有值为24的行的索引。
* 对于这些行,我们将主键列的值替换为0.
* 既然我们替换了主键列的值,flights也不再按照 hour列排序了。因此,主键被自动去除了,它被设置为NULL。

现在,flights的hour列里,应该没有24了。

flights[, sort(unique(hour))]
#  [1]  0  1  2  3  4  5  6  7  8  9 10 11 12 13 14 15 16 17 18 19 20 21 22 23

e) 用参数by聚合

我们先将 origin列 和 dest列设置为主键。

setkey(flights, origin, dest)
key(flights)
# [1] "origin" "dest"

-获取每个月从“JFK”起飞的航班的最大起飞延误时间,按月排序

ans <- flights["JFK", max(dep_delay), keyby=month]
head(ans)
#    month   V1
# 1:     1  881
# 2:     2 1014
# 3:     3  920
# 4:     4 1241
# 5:     5  853
# 6:     6  798
key(ans)
# [1] "month"

说明:

* 我们对主键 origin列进行subset,得到了所有起飞机场是“JFK”的行索引。
* 现在我们已经得到这些行的索引了,我们只需要两列-用来分组的month列,和用来计算每组最大值的dep_delay列。data.table的查询都被优化过了,因此在参数i取得的行的基础上,再subset这两列,效率和内存开销都很可观。
* 在subset的时候,我们按month分组,再计算dep_delay列的最大值。
* 我们使用参数keyby来自动将month设置为结果的主键。现在我们理解了为什么叫keyby吧。它使得结果不仅按month列排序,而且将month设置为主键。

3. 另外两个参数mult和nomatch

a) 参数mult

对于每次查询,我们可以通过参数mult,指定所有符合条件的行“all”都被返回,还是只返回第一行“first”或者最后一行“last”。默认是所有的行“all”。
-获取符合origin = “JFK” 且 dest = “MIA”的数据的第一行

flights[.("JFK", "MIA"), mult="first"]
#    year month day dep_time dep_delay arr_time arr_delay cancelled carrier tailnum flight origin
# 1: 2014     1   1      546         6      853         3         0      AA  N5CGAA   2243    JFK
#    dest air_time distance hour min
# 1:  MIA      157     1089    5  46

-获取符合origin = “LGA”或”JFK”或”EWR” 且 dest = “XNA”的数据的最后一行

flights[.(c("LGA", "JFK", "EWR"), "XNA"), mult="last"]
#    year month day dep_time dep_delay arr_time arr_delay cancelled carrier tailnum flight origin
# 1: 2014     5  23     1803       163     2003       148         0      MQ  N515MQ   3553    LGA
# 2:   NA    NA  NA       NA        NA       NA        NA        NA      NA      NA     NA    JFK
# 3: 2014     2   3     1208       231     1516       268         0      EV  N14148   4419    EWR
#    dest air_time distance hour min
# 1:  XNA      158     1147   18   3
# 2:  XNA       NA       NA   NA  NA
# 3:  XNA      184     1131   12   8

说明:

* JFK”, “XNA”不匹配flights的任何一条数据,因此返回 NA。
* 再强调一下,参数i里查询语句的第二个主键dest列,"XNA"会被自动补足成跟第一个主键的取值等长,也就是3。

b) 参数nomatch

我们可以通过参数nomatch,指定在没有找到符合条件的数据的情况下,是返回NA呢,还是跳过(不返回)。
-跟前一个例子一样,选取能找到的数据

flights[.(c("LGA", "JFK", "EWR"), "XNA"), mult="last", nomatch = 0L]
#    year month day dep_time dep_delay arr_time arr_delay cancelled carrier tailnum flight origin
# 1: 2014     5  23     1803       163     2003       148         0      MQ  N515MQ   3553    LGA
# 2: 2014     2   3     1208       231     1516       268         0      EV  N14148   4419    EWR
#    dest air_time distance hour min
# 1:  XNA      158     1147   18   3
# 2:  XNA      184     1131   12   8

说明:

* nomatch的默认是是NA。设置 nomatch = 0L 跳过哪些不存在的数据。
* JFK”, “XNA”不匹配flights的任何一条数据,因此就被跳过了。

4. 二分法搜索 vs 向量扫描

到目前为止,我们学习了如何设置和使用主键来subset。但是它的优点是什么呢?举个例子,除了这么做:

# key by origin,dest columns
flights[.("JFK", "MIA")]

我们还可以这样做:

flights[origin == "JFK" & dest == "MIA"]

一个显而易见的优点是,看上去更短。但是它的优点可不只是这个,事实上,基于二分法搜索的subset非常快速。

a) 二分法搜索

为了说明,我们创建一个有两千万行、三列的样本数据,将它的主键设置为x列和y列。

set.seed(2L)
N = 2e7L
DT = data.table(x = sample(letters, N, TRUE), 
                y = sample(1000L, N, TRUE), 
                val=runif(N), key = c("x", "y"))
print(object.size(DT), units="Mb")
# 381.5 Mb

key(DT)
# [1] "x" "y"

DT大约有 380 MB。这不算特别大,但是足够我们体现二分法搜索的优点了。
用第一讲data.table 介绍我们学过的知识,我们可以subset 那些 x = “g” 和 y = 877 的行。

## (1) Usual way of subsetting - vector scan approach
t1 <- system.time(ans1 <- DT[x == "g" & y == 877L])
t1
#    user  system elapsed 
#   0.871   0.022   0.919
head(ans1)
#    x   y       val
# 1: g 877 0.3946652
# 2: g 877 0.9424275
# 3: g 877 0.7068512
# 4: g 877 0.6959935
# 5: g 877 0.9673482
# 6: g 877 0.4842585
dim(ans1)
# [1] 761   3

现在我们用主键来试着做一下。

## (2) Subsetting using keys
t2 <- system.time(ans2 <- DT[.("g", 877L)])
t2
#    user  system elapsed 
#   0.001   0.000   0.002
head(ans2)
#    x   y       val
# 1: g 877 0.3946652
# 2: g 877 0.9424275
# 3: g 877 0.7068512
# 4: g 877 0.6959935
# 5: g 877 0.9673482
# 6: g 877 0.4842585
dim(ans2)
# [1] 761   3

identical(ans1$val, ans2$val)
# [1] TRUE

(2)比(1)快了460倍!

b) 为什么用主键subset能这么快?

为了理解这些,我们先看第一种方法(1)向量扫描。

向量扫描
* 在所有两千条数据中,逐行搜索 x列里值为“g”的行。这会生成一个有两千行的逻辑向量,根据和x列的批评结果,它每个元素的取值可能是TRUE, FALSE 以及 NA。
* 相似的,在所有两千条数据中,逐行搜索 y列里值为“877”的行,再保存在另一个逻辑向量里面。
* 操作符"&"对上面两个逻辑向量进行“且”运算,返回结果为TRUE的行
这就是所谓的“向量扫描”。效率非常低,特别是数据量很大、需要重复subset的时候。因为它每次不得不对整个数据全盘扫描。

现在我们开看看第二种方法(2)二分法搜索。回忆一下前面“a)什么是主键”里的定义,根据主键列重新排序。既然数据被排序了,我们就不需要再对整个数据进行扫描。我们用二分法搜索的时间开销是 O(log n),而向量扫描的时间开销是 O(n),其中n是data.table的行数。

二分法搜索
这里有一个简单的示例。看看下面这组排过序的数字:
1, 5, 10, 19, 22, 23, 30
假设我们希望找到数字1的位置,用二分法搜索(因为这组数字是排过序的),我们是这么做的:
* 从中间的数开始,它是19,不是1,而且 1<19。
* 既然我们要找的数字1小于19,那它应该排在19前面。所以我们可以无视19后面的那一半数据,因为它们都大于19.
* 现在我们的数据只剩下1, 5, 10。再找到中间的数5,它不是1,而且 1<5。
* 现在我们的数据只剩下1。符合条件。这就是我们要找的数。
相反的,向量扫描需要扫描所有的数字,在这个例子中是7。

显而易见的,我们每次搜索的时候,搜索量都是原先的一半。这就是为什么基于二分法搜索的subset是如此的快。
因为data.table的行在内存中是连续存储的,这种subset的操作也很节省缓存,这有利于处理速度。
另外,既然我们不需要创建超大(跟原数据有同样多的行)的逻辑向量,就能取得匹配的行的索引,这种subset也能节省内存。

总结

在这一讲,我们学习了通过设置主键来subset行。设置主键使用了二分法搜索似的subset的操作变得惊人的快。特别的,我们学习了如何:

* 设置主键,并使用主键subset行。
* 更快的在参数i里通过主键subset行的索引。
* 将主键和参数j、参数by一起使用。注意参数j和参数by的使用方法和以前一样。

我们大概不需要用主键来进行聚合的操作,除非数据了极其巨大,使得我们需要重复地做很多次subset,这就会让效果变得很醒目。
然而,当连结两个data.table的时候,设置主键是必要的。这是下一讲的主题。
我们会详细讲解根据主键列来连结两个data.table。