使用 Unicode 排序算法在 Ruby 中排序

Posted

技术标签:

【中文标题】使用 Unicode 排序算法在 Ruby 中排序【英文标题】:Sorting in Ruby using the Unicode collation algorithm 【发布时间】:2019-10-23 08:05:22 【问题描述】:

Ruby 和 Postgres 的排序略有不同,这在我的项目中造成了一些微妙的问题。有两个问题:重音字符和空格。看起来 Ruby 正在按 ASCII 顺序排序,而 Postgres 正在使用正确的 Unicode collation algorithm 进行排序。

Heroku Postgres 11.2。数据库排序规则为en_US.UTF-8

psql (11.3, server 11.2 (Ubuntu 11.2-1.pgdg16.04+1))
...
=> select 'quia et' > 'qui qui';
 ?column? 
----------
 f
(1 row)
=> select 'quib' > 'qüia';
 ?column? 
----------
 t
(1 row)

Heroku 上的 Ruby 2.4.4。

Loading production environment (Rails 5.2.2.1)
[1] pry(main)> 'quia et' > 'qui qui'
=> true
[2] pry(main)> 'quib' > 'qüia'
=> false
[3] pry(main)> ENV['LANG']
=> "en_US.UTF-8"

我可以修复重音字符的处理,但我无法让 Ruby 正确处理空格。例如,这是它们对同一个列表进行排序的方式。

Postgres: ["hic et illum", "quia et ipsa", "qui qui non"]
Ruby:     ["hic et illum", "qui qui non", "quia et ipsa"]

我试过icunicode gem:

array.sort_by |s| s.unicode_sort_key

这会处理重音字符,但不能正确处理空格。

如何让 Ruby 使用 Unicode 排序算法进行排序?

更新在Unicode® Technical Standard #10 中可以找到更全面的示例。这些顺序正确。

  [
    "di Silva   Fred",
    "diSilva    Fred",
    "disílva    Fred",
    "di Silva   John",
    "diSilva    John",
    "disílva    John"
  ]

【问题讨论】:

也许可以试试icu4r? @daxim 那是 11 岁。但我确实尝试过with ffi-icu from this answer。它似乎只是让算法的一部分正确,它不处理空格。 ["di Silva Fred", "di Silva John", "diSilva Fred", "disílva Fred", "diSilva John", "disílva John"] 【参考方案1】:

我非常接近使用这个算法和icunicode gem。

require 'icunicode'

def database_sort_key(key)
  key.gsub(/\s+/,'').unicode_sort_key
end

array.sort_by  |v|
  [database_sort_key(v), v.unicode_sort_key]

首先,我们使用去掉空格的 unicode 排序键进行排序。然后,如果它们相同,我们将按原始值的 unicode 排序键进行排序。

这解决了unicode_sort_key 中的一个弱点:它不认为空格是弱点。

2.4.4 :007 > "fo p".unicode_sort_key.bytes.map  |b| b.to_s(16) 
 => ["33", "45", "4", "47", "1", "8", "1", "8"] 
2.4.4 :008 > "foo".unicode_sort_key.bytes.map  |b| b.to_s(16) 
 => ["33", "45", "45", "1", "7", "1", "7"] 

请注意,fo p 中的空格与任何其他字符一样重要。这导致 'fo p' < 'foo' 不正确。我们通过在生成密钥之前先去除空格来解决这个问题。

2.4.4 :011 > "fo p".gsub(/\s+/, '').unicode_sort_key.bytes.map  |b| b.to_s(16) 
 => ["33", "45", "47", "1", "7", "1", "7"] 
2.4.4 :012 > "foo".gsub(/\s+/, '').unicode_sort_key.bytes.map  |b| b.to_s(16) 
 => ["33", "45", "45", "1", "7", "1", "7"] 

现在'foo' < 'fo p' 是正确的。

但是由于规范化,我们可能会得到在去除空格后看起来相同的值,fo o 应该小于foo。因此,如果database_sort_keys 相同,我们将比较它们的普通unicode_sort_keys。

有一些边缘情况是错误的。 foo 应该小于 fo o 但这会倒退。

这里是 Enumerable 方法。

module Enumerable
  # Just like `sort`, but tries to sort the same as the database does
  # using the proper Unicode collation algorithm. It's close.
  #
  # Differences in spacing, cases, and accents are less important than
  # character differences.
  #
  # "foo" < "fo p" o vs p is more important than the space difference
  # "Foo" < "fop" o vs p is more important than is case difference
  # "föo" < "fop" o vs p is more important than the accent difference
  #
  # It does not take a block.
  def sort_like_database(&block)
    if block_given?
      raise ArgumentError, "Does not accept a block"
    else
      # Sort by the database sort key. Two different strings can have the
      # same keys, if so sort just by its unicode sort key.
      sort_by  |v| [database_sort_key(v), v.unicode_sort_key] 
    end
  end

  # Just like `sort_by`, but it sorts like `sort_like_database`.
  def sort_by_like_database(&block)
    sort_by  |v|
      field = block.call(v)
      [database_sort_key(field), field.unicode_sort_key]
    
  end

  # Sort by the unicode sort key after stripping out all spaces. This provides
  # a decent simulation of the Unicode collation algorithm and how it handles
  # spaces.
  private def database_sort_key(key)
    key.gsub(/\s+/,'').unicode_sort_key
  end
end

【讨论】:

【参考方案2】:

您的用例是否允许简单地将排序委托给 Postgres,而不是尝试在 Ruby 中重新创建它?

这里的部分困难在于没有一个单一正确的排序方法,但是任何可变元素都可能导致最终排序顺序中相当大的差异,例如见the section on variable weighting。

例如,像 twitter-cldr-rb 这样的 gem 有一个相当健壮的 UCA 实现,并由一个全面的测试套件支持 - 但针对与 Postgres 实现不同的不可忽略的测试用例(Postgres 似乎使用移位修剪的变体)。

test cases 的绝对数量意味着您无法保证一个可行的解决方案在所有情况下都与 Postgres 排序顺序匹配。例如。它会正确处理 en/em dash,甚至是表情符号吗?您可以 fork 和修改 twitter-cldr-rb gem,但我怀疑这不是一件小事!

如果您需要处理数据库中不存在的值,您可以要求 Postgres 使用VALUES 列表以轻量级方式对它们进行排序:

sql = "SELECT * FROM (VALUES ('de luge'),('de Luge'),('de-luge'),('de-Luge'),('de-luge'),('de-Luge'),('death'),('deluge'),('deLuge'),('demark')) AS t(term) ORDER BY term ASC"
ActiveRecord::Base.connection.execute(sql).values.flatten

这显然会导致到 Postgres 的往返,但应该很快。

【讨论】:

有趣。 UI 提供排序结果的当前用例测试。排序由 PG 完成,但由 Ruby 检查。我只关心它是按特定字段排序的,确切的细节并不重要。我会试一试。【参考方案3】:

如果有机会将 Ruby 更新到 2.5.0,它会附带String#unicode_normalize。后者将使任务变得更容易:您所需要的只是在摆脱非字母之前将字符串规范化为分解形式。在输入中,我们有 4 个字符串。在qüia 中有组合的变音符号,在'qü ic' 中有一个组合字符:

['quid', 'qüia', 'qu ib', 'qü ic'].map &:length
#⇒ [4, 5, 5, 5]

然后,瞧:

['quid', 'qüia', 'qu ib', 'qü ic'].sort_by do |s|
  s.unicode_normalize(:nfd).gsub(/\PL+/, '')
end
#⇒ ["qüia", "qu ib", "qü ic", "quid"]

要排序不区分大小写,String#downcase 在排序器中:

["di Silva Fred", "diSilva Fred", "disílva Fred",
 "di Silva John", "diSilva John", "disílva John"].sort_by do |s|
  s.downcase.unicode_normalize(:nfd).gsub(/\PL+/, '')
end
#⇒ ["di Silva Fred", "diSilva Fred", "disílva Fred",
#   "di Silva John", "diSilva John", "disílva John"]

【讨论】:

感谢您的回答。这不会正确地对 UTS#10 中的示例进行排序。它应该是["di Silva Fred", "diSilva Fred", "disílva Fred", "di Silva John", "diSilva John", "disílva John"],但这会产生["diSilva Fred", "di Silva Fred", "di Silva John", "diSilva John", "disílva Fred", "disílva John"] 您没有说您希望它不区分大小写。更新了答案。 我需要根据问题中链接到的 UTS#10 中的 Unicode 排序算法对其进行排序。我在问题中放了一个更详细的例子来说明清楚。这是一种多通道算法,它赋予不同类型的差异不同的权重;大小写差异的权重非常低。如果您将di silva fred 放入混合中,您会看到您的解决方案会跳来跳去,因为它与di Silva Fred 排序相同。对未修改键进行二次排序会有所帮助。 a.shuffle.sort_by |s| [s.downcase.unicode_normalize(:nfd).gsub(/\PL+/, ''), s.unicode_normalize(:nfd)]

以上是关于使用 Unicode 排序算法在 Ruby 中排序的主要内容,如果未能解决你的问题,请参考以下文章

有没有办法在 Ruby 中使用带有任何方法的 while 循环来执行冒泡排序算法?

ruby Ruby的算法和数据结构 - #3快速排序

ruby Ruby的算法和数据结构 - #2选择排序

ruby Ruby的算法和数据结构 - #1插入排序

在没有排序函数的数组中对字符串进行排序 - Ruby

使用 SC 排序规则的 SQL Server Unicode 查询