在一个字符串集合中搜索最快的方法

81

问题:

我有一个文本文件,其中包含大约120,000个用户(字符串),我想将它们存储在一个集合中,并稍后在该集合上执行搜索。

每当用户更改 TextBox的文本时,搜索方法都会发生,并且结果应为包含 TextBox文本的字符串。

我不必更改列表,只需提取结果并将它们放入 ListBox中。

到目前为止我尝试过的事情:

我尝试了两种不同的集合/容器,我从外部文本文件中转储字符串条目(当然只在一次):

  1. List<string> allUsers;
  2. HashSet<string> allUsers;

使用以下LINQ查询:

allUsers.Where(item => item.Contains(textBox_search.Text)).ToList();

我的搜索事件(用户更改搜索文本时触发):

private void textBox_search_TextChanged(object sender, EventArgs e)
{
    if (textBox_search.Text.Length > 2)
    {
        listBox_choices.DataSource = allUsers.Where(item => item.Contains(textBox_search.Text)).ToList();
    }
    else
    {
        listBox_choices.DataSource = null;
    }
}

结果:

两者都给了我很差的响应时间(每次按键之间大约1-3秒)。

问题:

您认为我的瓶颈在哪里?是我使用的集合吗?搜索方法?还是两者都有?

如何获得更好的性能和更流畅的功能?


10
这里 HashSet<T> 无法帮助你,因为你正在搜索字符串的 部分 - Dennis
8
请查看后缀数组 - CodesInChaos
67
不要询问“最快的方法是什么”,因为那需要进行数周到数年的研究。相反,说“我需要一个在不到30毫秒内运行的解决方案”,或者根据你的性能目标说出具体时间。你不需要最快的设备,你需要一个足够快的设备。 - Eric Lippert
44
另外,使用性能分析器。不要猜测哪部分代码运行较慢;这种猜测通常是错误的。瓶颈可能出现在意想不到的地方。 - Eric Lippert
4
@Basilevs曾经写过一个美妙的O(1)哈希表,但实际上运行得非常缓慢。我进行了性能剖析,发现在每次搜索时,它都会调用一个方法来询问注册表“我们现在是否在泰国?” 不缓存用户是否位于泰国是导致O(1)代码瓶颈的原因。瓶颈的位置可能非常出人意料。请使用分析器。 - Eric Lippert
显示剩余10条评论
17个回答

48

你可以考虑在后台线程执行过滤任务,当完成任务时调用回调方法,或者如果输入改变了就重新开始过滤。

总体思路是能够像这样使用它:

public partial class YourForm : Form
{
    private readonly BackgroundWordFilter _filter;

    public YourForm()
    {
        InitializeComponent();

        // setup the background worker to return no more than 10 items,
        // and to set ListBox.DataSource when results are ready

        _filter = new BackgroundWordFilter
        (
            items: GetDictionaryItems(),
            maxItemsToMatch: 10,
            callback: results => 
              this.Invoke(new Action(() => listBox_choices.DataSource = results))
        );
    }

    private void textBox_search_TextChanged(object sender, EventArgs e)
    {
        // this will update the background worker's "current entry"
        _filter.SetCurrentEntry(textBox_search.Text);
    }
}

一个大致的草图可能是这样的:

public class BackgroundWordFilter : IDisposable
{
    private readonly List<string> _items;
    private readonly AutoResetEvent _signal = new AutoResetEvent(false);
    private readonly Thread _workerThread;
    private readonly int _maxItemsToMatch;
    private readonly Action<List<string>> _callback;

    private volatile bool _shouldRun = true;
    private volatile string _currentEntry = null;

    public BackgroundWordFilter(
        List<string> items,
        int maxItemsToMatch,
        Action<List<string>> callback)
    {
        _items = items;
        _callback = callback;
        _maxItemsToMatch = maxItemsToMatch;

        // start the long-lived backgroud thread
        _workerThread = new Thread(WorkerLoop)
        {
            IsBackground = true,
            Priority = ThreadPriority.BelowNormal
        };

        _workerThread.Start();
    }

    public void SetCurrentEntry(string currentEntry)
    {
        // set the current entry and signal the worker thread
        _currentEntry = currentEntry;
        _signal.Set();
    }

    void WorkerLoop()
    {
        while (_shouldRun)
        {
            // wait here until there is a new entry
            _signal.WaitOne();
            if (!_shouldRun)
                return;

            var entry = _currentEntry;
            var results = new List<string>();

            // if there is nothing to process,
            // return an empty list
            if (string.IsNullOrEmpty(entry))
            {
                _callback(results);
                continue;
            }

            // do the search in a for-loop to 
            // allow early termination when current entry
            // is changed on a different thread
            foreach (var i in _items)
            {
                // if matched, add to the list of results
                if (i.Contains(entry))
                    results.Add(i);

                // check if the current entry was updated in the meantime,
                // or we found enough items
                if (entry != _currentEntry || results.Count >= _maxItemsToMatch)
                    break;
            }

            if (entry == _currentEntry)
                _callback(results);
        }
    }

    public void Dispose()
    {
        // we are using AutoResetEvent and a background thread
        // and therefore must dispose it explicitly
        Dispose(true);
    }

    private void Dispose(bool disposing)
    {
        if (!disposing)
            return;

        // shutdown the thread
        if (_workerThread.IsAlive)
        {
            _shouldRun = false;
            _currentEntry = null;
            _signal.Set();
            _workerThread.Join();
        }

        // if targetting .NET 3.5 or older, we have to
        // use the explicit IDisposable implementation
        (_signal as IDisposable).Dispose();
    }
}

另外,当父Form被释放时,您应该实际上处理_filter实例。这意味着您应该打开并编辑您的FormDispose方法(在YourForm.Designer.cs文件内),使其类似于以下内容:

// inside "xxxxxx.Designer.cs"
protected override void Dispose(bool disposing)
{
    if (disposing)
    {
        if (_filter != null)
            _filter.Dispose();

        // this part is added by Visual Studio designer
        if (components != null)
            components.Dispose();
    }

    base.Dispose(disposing);
}

在我的机器上,这个程序的速度相当快,所以在采取更复杂的解决方案之前,你应该先进行测试和性能分析。

话虽如此,“更复杂的解决方案”可能是将最近几个结果存储在字典中,然后仅在新条目与前一个或最后一个字符不同的情况下过滤它们。


我刚测试了你的解决方案,它完美地工作了!做得好。 我唯一的问题是无法编译_signal.Dispose();(保护级别错误)。 - etaiso
这很奇怪。AutoResetEvent 继承自 WaitHandle,该类实现了 IDisposable.Dispose(),这意味着 Dispose 必须是一个公共方法。也许这是在 Mono 上运行的?在此之前我真的没遇到过这样的问题。 - vgru
1
@Groo 这是显式实现,意味着您不能直接调用它。您应该使用using块或调用WaitHandle.Close() - Matthew Watson
1
现在明白了,这个方法在 .NET 4 中被公开。.NET 4 的 MSDN 页面将其列在公共方法下,而 .NET 3.5 的页面将其显示为受保护的方法之一。这也解释了为什么在 Mono 的 WaitHandle 源代码库中有一个条件定义。 - vgru
1
@Groo 抱歉,我本应该提到我是在谈论旧版的 .Net - 对于造成的混淆感到抱歉!然而请注意,他不需要进行强制转换 - 他可以直接调用.Close(),它会自动执行.Dispose() - Matthew Watson
显示剩余7条评论

36

我已经进行了一些测试,搜索一个包含120,000个项目的列表并将条目填充到新列表中只需要很少的时间(即使所有字符串都匹配,也大约只需1/50秒)。

因此,您看到的问题必须来自于数据源的填充,即在此处:

listBox_choices.DataSource = ...

我怀疑你只是将太多的项目放入了列表框中。

也许你应该尝试将其限制在前20个条目中,就像这样:

listBox_choices.DataSource = allUsers.Where(item => item.Contains(textBox_search.Text))
    .Take(20).ToList();

还要注意(正如其他人指出的那样),您正在访问allUsers中每个项目的TextBox.Text属性。可以轻松地通过以下方式进行修复:

string target = textBox_search.Text;
listBox_choices.DataSource = allUsers.Where(item => item.Contains(target))
    .Take(20).ToList();

然而,我计算了访问 TextBox.Text 500,000 次需要多长时间,并且只需要 0.7 秒,远少于 OP 中提到的 1 到 3 秒。尽管如此,这仍然是一个值得优化的地方。


1
谢谢Matthew。我尝试了你的解决方案,但我不认为问题在于ListBox的人口。 我认为我需要更好的方法,因为这种过滤非常幼稚(例如-搜索“abc”返回0个结果,那么我甚至不应该寻找“abcX”等等..) - etaiso
@etaiso没错(即使Matthew的解决方案可能非常好,如果您不需要预设所有匹配项),这就是为什么我建议作为第二步进行细化搜索而不是每次执行完整搜索。 - Adriano Repetti
5
好的,搜索时间是可以忽略不计的,就像我之前说的一样。我尝试了使用120,000个字符串进行搜索,无论是搜索一个没有匹配结果的非常长的字符串,还是搜索一个有许多匹配结果的非常短的字符串,都只需要不到1/50秒的时间。 - Matthew Watson
3
textBox_search.Text对时间有影响吗?获取文本框的Text属性对于120k个字符串,可能会向编辑控件窗口发送120k条消息。 - Gabe
@Gabe 是的,它确实可以。请查看我的答案以获取详细信息。 - Andris
@Gabe 好观点!我会把它加入答案以保证完整性。 - Matthew Watson

28

使用后缀树作为索引。或者仅构建一个排序的字典,将每个名称的每个后缀与相应名称的列表相关联。

对于输入:

Abraham
Barbara
Abram

结构将会如下:

a -> Barbara
ab -> Abram
abraham -> Abraham
abram -> Abram
am -> Abraham, Abram
aham -> Abraham
ara -> Barbara
arbara -> Barbara
bara -> Barbara
barbara -> Barbara
bram -> Abram
braham -> Abraham
ham -> Abraham
m -> Abraham, Abram
raham -> Abraham
ram -> Abram
rbara -> Barbara

搜索算法

假设用户输入为“bra”。

  1. 对字典进行二分查找,以查找用户输入或其所在位置。这样我们可以找到“barbara”——最后一个小于“bra”的键。它被称为“bra”的下限。搜索将需要对数时间。
  2. 从找到的键开始迭代,直到用户输入不再匹配。这将给出“bram”-> Abram 和 “braham” -> Abraham。
  3. 连接迭代结果(Abram,Abraham)并输出。

此类树旨在快速搜索子字符串,其性能接近O(log n)。我相信这种方法足够快速,可以直接由GUI线程使用。此外,由于没有同步开销,它比线程化解决方案更快。


据我所知,后缀数组通常比后缀树更好的选择。实现更容易,内存使用更低。 - CodesInChaos
此外,似乎数组(和原始ST)是设计用于处理大文本的,而在这里我们有大量短块需要处理,这是不同的任务。 - Basilevs
@OrangeDog,如何使用哈希映射来搜索前缀?我找不到一个具有内置二分的集合在.NET中, 你能提供一个用作搜索树的建议吗? - Basilevs
@jnovacho,除了能够搜索子字符串之外,没有任何优势。 - Basilevs
链接到所有子字符串会使集合大小与单词长度成比例增加(虽然 trie 可能节省字符串长度,但我们仍然必须处理引用)。由于它已经非常大了,我不会尝试这样做。 - Basilevs
显示剩余5条评论

15
你需要一个文本搜索引擎(如 Lucene.Net)或数据库(你可以考虑嵌入式数据库,如 SQL CESQLite 等)。换句话说,你需要一个索引搜索。基于哈希的搜索在这里不适用,因为你正在查找子字符串,而基于哈希的搜索适用于查找精确值。

否则,将会是一种迭代搜索,需要遍历整个集合。


索引是基于哈希的搜索。您只需将所有子字符串添加为键,而不仅仅是值。 - OrangeDog
3
@OrangeDog不同意。索引搜索可以通过索引键实现基于哈希的搜索,但这并非必要,并且它并不是基于字符串值本身的哈希搜索。 - Dennis
@Dennis 同意。+1 取消幽灵-1。 - user
+1 是因为像文本搜索引擎这样的实现比 string.Contains 更加智能化。例如,在 bcaaaabaa 中搜索 ba 将会得到一个(索引)跳表。首先考虑第一个 b,但是由于下一个字符是 c,所以它不匹配,因此会跳到下一个 b - Caramiriel

12

更新:

我进行了一些性能测试。

(更新 3)

  • 列表内容:生成从0到2,499,999的数字
  • 筛选文本:123(20,477个结果)
  • 核心i5-2500、Win7 64位、8GB RAM
  • VS2012 + JetBrains dotTrace

最初的2,500,000条记录的测试运行花费了20,000毫秒。

主要问题是每次调用Contains内的textBox_search.Text都会导致对文本框的昂贵get_WindowText方法的调用。只需将代码更改为:

    var text = textBox_search.Text;
    listBox_choices.DataSource = allUsers.Where(item => item.Contains(text)).ToList();

将执行时间降至1.858毫秒

更新2:

另外两个主要的瓶颈现在是对string.Contains的调用(约占执行时间的45%)和在set_Datasource中更新listbox元素(30%)。

我们可以通过创建后缀树来权衡速度和内存使用,以减少必要的比较次数并将一些处理时间从按键后的搜索转移到从文件加载名称,这可能更适合用户。

为了提高将元素加载到列表框中的性能,我建议仅加载前几个元素,并指示用户还有其他元素可用。这样,您就可以向用户提供有关结果可用的反馈,以便他们可以通过输入更多字母来细化搜索,或者按一下按钮加载完整列表。

使用BeginUpdateEndUpdateset_Datasource的执行时间没有改变。

正如其他人在这里所指出的那样,LINQ查询本身运行得非常快。我相信瓶颈是列表框本身的更新。你可以尝试类似于:

if (textBox_search.Text.Length > 2)
{
    listBox_choices.BeginUpdate(); 
    listBox_choices.DataSource = allUsers.Where(item => item.Contains(textBox_search.Text)).ToList();
    listBox_choices.EndUpdate(); 
}

希望这可以帮助到你。


我认为这样做不会有任何改进,因为BeginUpdateEndUpdate是用于逐个添加项目或使用AddRange()时使用的。 - etaiso
这取决于DataSource属性的实现方式。或许值得一试。 - Andris
你的性能分析结果与我的非常不同。我能够在30毫秒内搜索120k个字符串,但将它们添加到列表框中需要4500毫秒。听起来你正在将2.5M个字符串添加到列表框中,而时间不到600毫秒。这怎么可能? - Gabe
@Gabe 在进行性能分析时,我使用了一个输入,其中过滤文本消除了原始列表的很大一部分。如果我使用一个输入,其中过滤文本不从列表中删除任何内容,我会得到与您类似的结果。我将更新我的回复以澄清我所测量的内容。 - Andris

12

可能也有一种“防抖动”类型的事件是有用的。与节流不同的是,它会等待一段时间(例如200 ms),在完成更改之前触发事件。

有关去抖功能的更多信息,请参见去抖和节流:一个可视化解释。我很感激本文重点介绍了JavaScript, 而不是C#,但原理相同。

其优点是,当您仍在输入查询时,它不会搜索。然后,它应该停止尝试同时执行两个搜索。


请查看Algorithmia库中EventThrotler类的C#实现,以了解事件节流器的实现:https://github.com/SolutionsDesign/Algorithmia/blob/master/SD.Tools.Algorithmia/GeneralDataStructures/EventThrottler.cs - Frans Bouma

11

在另一个线程上运行搜索,并在该线程正在运行时显示一些加载动画或进度条。

您还可以尝试并行化LINQ查询。

var queryResults = strings.AsParallel().Where(item => item.Contains("1")).ToList();

这里有一个基准测试,演示了 AsParallel() 的性能优势:

{
    IEnumerable<string> queryResults;
    bool useParallel = true;

    var strings = new List<string>();

    for (int i = 0; i < 2500000; i++)
        strings.Add(i.ToString());

    var stp = new Stopwatch();

    stp.Start();

    if (useParallel)
        queryResults = strings.AsParallel().Where(item => item.Contains("1")).ToList();
    else
        queryResults = strings.Where(item => item.Contains("1")).ToList();

    stp.Stop();

    Console.WriteLine("useParallel: {0}\r\nTime Elapsed: {1}", useParallel, stp.ElapsedMilliseconds);
}

1
我知道这是可能的。但我的问题是,我是否可以缩短这个过程,以及如何做到? - etaiso
1
这不是 PLINQ 的好选择,因为 String.Contains 方法不够昂贵。http://msdn.microsoft.com/en-us/library/dd997399.aspx - Tim Schmelter
1
@TimSchmelter 当我们谈论大量字符串时,确实如此! - animaonline
4
@TimSchmelter ,我不知道你试图证明什么,使用我提供的代码可能会增加OP的性能,这里有一个展示它如何工作的基准测试:http://pastebin.com/ATYa2BGt。 - animaonline
1
@animaonline:我改正了。尽管这与MSDN相矛盾,但AsParallel似乎确实更快一些,尤其是对于这些示例字符串。 - Tim Schmelter
显示剩余10条评论

9
假设您只是通过前缀进行匹配,那么您需要的数据结构称为trie,也称为“前缀树”。您现在正在使用的IEnumerable.Where方法将不得不在每次访问时迭代字典中的所有项。此线程展示了如何在C#中创建trie。

1
假设他正在使用前缀过滤记录。 - Tarec
1
请注意他正在使用String.Contains()方法而不是String.StartsWith()方法,所以它可能不完全符合我们的需求。然而,你的想法无疑比在前缀方案中使用StartsWith()扩展进行普通过滤要好得多。 - Tarec
如果他的意思是“以...开始”,那么可以将 Trie 与后台工作进程方法相结合,以提高性能。 - Frames Catherine White

8
WinForms ListBox控件真的是你在这里的敌人。它加载记录速度慢,而且滚动条会阻挡你显示全部120,000个记录。
试试使用一个老式的DataGridView,将数据源设置为只有一个列[UserName]的DataTable来保存你的数据:
private DataTable dt;

public Form1() {
  InitializeComponent();

  dt = new DataTable();
  dt.Columns.Add("UserName");
  for (int i = 0; i < 120000; ++i){
    DataRow dr = dt.NewRow();
    dr[0] = "user" + i.ToString();
    dt.Rows.Add(dr);
  }
  dgv.AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill;
  dgv.AllowUserToAddRows = false;
  dgv.AllowUserToDeleteRows = false;
  dgv.RowHeadersVisible = false;
  dgv.DataSource = dt;
}

然后在你的文本框的TextChanged事件中使用DataView来过滤数据:
private void textBox1_TextChanged(object sender, EventArgs e) {
  DataView dv = new DataView(dt);
  dv.RowFilter = string.Format("[UserName] LIKE '%{0}%'", textBox1.Text);
  dgv.DataSource = dv;
}

2
当其他人都试图优化只需要30毫秒的搜索时,你是唯一一个意识到问题实际上在于填充列表框的人,因此得到了+1。 - Gabe
取决于您是否需要列表框中的所有信息?列表框中最多只应该有几十个项目。Groo 的实现限制了输出,因此这不是问题。我发现这个线程是因为我正在使用大约 50,000 个列表的完全相同的策略。只要您不需要将120,000个项目放在列表框中,Groo的建议就非常完美。 - rune711

7
首先,我会更改ListControl如何查看您的数据源,您正在将结果IEnumerable<string>转换为List<string>。特别是当您只输入了几个字符时,这可能是低效的(也是不必要的)。不要制作昂贵的数据副本
  • 我会将.Where()结果包装到一个仅实现所需内容(搜索)的集合中。这将使您避免为每个键入的字符创建一个新的大列表。
  • 作为替代方案,我会避免使用LINQ,并编写更具体(和优化的)代码。将列表保留在内存中,并构建匹配索引的数组,重复使用数组,这样您就不必为每次搜索重新分配它。
其次,当小列表足够时,请勿在大列表中进行搜索。当用户开始输入“ab”并添加“c”时,您不需要在大列表中重新搜索,在过滤列表中搜索就足够了(而且更快)。尽可能完善搜索,不要每次执行完整搜索。

第三步可能更难: 保持数据有序以便快速搜索。现在,您需要更改用于存储数据的结构。想象一棵像这样的树:

A        B         C
 添加     更好      天花板
 在上方   骨头      等高线

如果您正在使用 ANSI 名称,则可以简单地使用数组实现此操作(否则最好使用字典)。按照以下方式构建列表(仅用于说明目的,它与字符串开头匹配):

var dictionary = new Dictionary<char, List<string>>();
foreach (var user in users)
{
    char letter = user[0];
    if (dictionary.Contains(letter))
        dictionary[letter].Add(user);
    else
    {
        var newList = new List<string>();
        newList.Add(user);
        dictionary.Add(letter, newList);
    }
}

搜索将使用第一个字符进行。
char letter = textBox_search.Text[0];
if (dictionary.Contains(letter))
{
    listBox_choices.DataSource =
        new MyListWrapper(dictionary[letter].Where(x => x.Contains(textBox_search.Text)));
}

请注意,我按照第一步的建议使用了MyListWrapper()(但为了简洁起见,我省略了第二个建议。如果您选择正确的字典键大小,您可以使每个列表变得短小快速,也许避免其他任何东西)。此外,请注意,您可以尝试为字典使用前两个字符(更多的列表和更短的列表)。如果您扩展这个,您将拥有一棵树(但我认为您没有那么多项目)。
许多不同的字符串搜索算法(以及相关的数据结构),只是举几个例子:
  • 基于有限状态自动机的搜索:采用这种方法,我们通过构建一个能够识别存储的搜索字符串的确定性有限自动机(DFA)来避免回溯。它们很昂贵,通常使用幂集构造来创建,但使用起来非常快。
  • 存根:Knuth-Morris-Pratt计算识别以要搜索的字符串作为后缀的输入的DFA,Boyer-Moore从针尖开始搜索,因此每次可以跳过整个针长。Baeza-Yates跟踪之前的j个字符是否是搜索字符串的前缀,因此适用于模糊字符串搜索。Bitap算法是Baeza-Yates方法的一种应用。
  • 索引方法:更快的搜索算法基于预处理文本。例如,在构建子字符串索引(例如后缀树或后缀数组)之后,可以快速找到模式的出现。
  • 其他变体:有些搜索方法(如trigram搜索)旨在查找搜索字符串与文本之间的“接近程度”得分,而不是“匹配/不匹配”。它们有时被称为“模糊”搜索。
关于并行搜索的几句话。虽然可能可行,但很少是简单的,因为使其并行的开销往往比搜索本身更高。我不会将搜索本身并行化(分区和同步很快就会变得过于昂贵,也许还很复杂),但我会将搜索移动到一个单独的线程中。如果主线程没有忙碌,用户在输入时就不会感到任何延迟(如果列表出现在200毫秒后,他们不会注意到,但如果他们必须等待50毫秒后才能继续输入,他们会感到不舒服)。当然,搜索本身必须足够快,在这种情况下,您不使用线程来加速搜索,而是使用线程来保持您的UI响应。请注意,单独的线程不会使您的查询更快,它不会挂起UI,但如果您的查询很慢,它仍然会很慢(而且您还必须处理多个连续请求)。

1
正如一些人已经指出的那样,OP并不想仅限于前缀结果(即他使用Contains而不是StartsWith)。顺便说一句,通常最好使用通用的ContainsKey方法来搜索键以避免装箱,甚至更好的方法是使用TryGetValue来避免两次查找。 - vgru
2
@Groo 你是对的,正如我所说,这只是为了举例说明。那段代码的重点不在于它是否能工作,而在于它是一个提示:如果你已经尝试了其他所有方法——避免复制、优化搜索、将其移动到另一个线程中——但仍然不够,那么你必须改变你正在使用的数据结构。这个例子只是为了保持简单,演示字符串开头的情况。 - Adriano Repetti
@Adriano,感谢您清晰详细的回答!我同意您提到的大部分内容,但正如Groo所说,保持数据组织的最后一部分在我的情况下不适用。但我认为,也许可以持有一个类似的字典,其中键是包含的字母(尽管仍然会有重复)。 - etaiso
经过快速检查和计算,“包含字母”的想法对于仅一个字符来说并不好(如果我们选择两个或更多字符的组合,最终会得到一个非常大的哈希表)。 - etaiso
@etaiso 是的,你可以保留一个由两个字母组成列表(以便快速缩小子列表),但是真正的树结构可能会更好(每个字母都链接到其后继,无论它在字符串中的位置如何,因此对于“HOME”,你有“H->O”、“O->M”和“M->E”。如果你正在搜索“om”,你将很快找到它。问题是它变得相当复杂,可能对你的情况来说过于复杂(在我看来)。 - Adriano Repetti

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