第20章优化代码的技巧
我们已经走了很长一段路。现在,您已经掌握了分析各种数据结构下算法的时间和空间复杂度的工具。有了这些概念,您现在能够编写快速、内存高效且美观的代码。
在我们旅程的最后一章中,我想给您留下一些优化代码的额外技巧。有时很难看到如何改进算法。多年来,我发现以下思维策略有助于我看清楚如何让我的代码更加高效。希望它们对您也有所帮助。
先决条件:确定您当前的大 O
在我们深入讨论优化技巧之前,重要的是要强调,在开始优化算法之前,有一件事是必须做的。
优化的前提是确定当前代码的效率。毕竟,如果不知道现在的算法有多快,就不可能使算法变得更快。
现在,您已经对大O符号及其各种类别有了全面的了解。一旦确定了算法所属的大O类别,就可以开始优化。
在本章的其余部分,我将把确定当前算法的大O步骤称为“先决条件”。
从这里开始:最佳可想象的大 O
虽然本章中的所有技术都很有用,但您会发现其中一些对特定情景非常方便,而其他一些则适用于其他情景。
然而,第一种技术适用于所有算法,并且应该是优化过程的第一步。
就是这样。
一旦确定了当前算法的效率(先决条件),就提出您认为是我所说的“最佳可想象的大O”。(当涉及速度时,我见过其他人将其称为“最佳可想象的运行时间”。)
本质上,最佳可想象的大O是您为手头问题梦想中的绝对最佳大O。这是您知道绝对不可能超越的大O。
例如,如果我们要编写一个打印数组中每个项的函数,我们可能会说这个任务的最佳可想象的大O是O(N)。因为我们必须打印数组中的每个N项,我们别无选择,只能处理每个N项。事实上,我们必须“访问”每个项才能打印它。因此,对于这种情况,O(N)是我们可以想象到的最佳大O。
因此,在优化算法时,我们需要确定两个大O。我们需要知道当前算法的大O(先决条件),并且需要想出手头任务可能具有的最佳可想象的大O。
如果这两个大O不相同,意味着我们有可以优化的空间。
举例来说,如果我的当前算法的运行时间为O(N^2),但最佳可想象的大O是O(N),那么我们现在有一个可以追求的改进目标。两个大O之间的差距显示了我们可以通过优化获得的潜在收益。
让我们总结一下这些步骤:
- 确定当前算法的大O类别。(这是先决条件。)
- 确定手头问题中您能梦想到的最佳可想象的大O。
- 如果最佳可想象的大O比当前大O更快,您现在可以尝试优化您的代码,目标是使算法尽可能接近最佳可想象的大O。
强调一点,不总是可能达到最佳可想象的大O。毕竟,仅仅因为您能够梦想到某事并不意味着它可以成为现实。
事实上,您的当前实现可能根本无法进一步优化。然而,最佳可想象的大O仍然是一个让我们在优化中有所追求的工具。
通常,我发现我可以成功将算法优化到当前大O和最佳可想象的大O之间的速度。
例如,如果我的当前实现是O(N^2),而我的最佳可想象的大O是O(log N),我将致力于优化我的算法以达到O(log N)。最后,如果我的优化将代码加速到“仅仅”是O(N),那仍然是一个很大的成功,而最佳可想象的大O也发挥了有用的作用。
拓展想象力
正如您所见,提出最佳可想象的大O的好处在于它为我们设定了一个优化目标。为了充分利用这一点,值得稍微拓展想象力,想出一个令人惊叹的最佳可想象的大O。事实上,我建议将您的最佳可想象的大O设定为您能想到的最快的大O,但又不是完全不可能的。
这里是我用来激发想象力的另一个心理技巧。我为手头的问题选择一个真正快速的大O——我们称之为“惊人的大O”。然后我问自己,“如果有人告诉我,他们知道如何在这个问题上实现惊人的大O,我会相信他们吗?”如果我会相信有人说他们找到了如何用惊人大O的效率解决这个问题的方法,那我就把惊人的大O作为我的最佳可想象的大O。
一旦我们知道了当前算法的大O和我们所追求的最佳可想象的大O,我们现在就为优化做好了准备。
在本章的其余部分,我们将探讨额外的优化技术和心理策略,这些策略可以帮助我们提高代码的效率。
神奇的查找
"我的最喜欢的优化技巧之一是问自己:“如果我能在O(1)时间内神奇地找到所需的信息,我能让我的算法变得更快吗?”如果答案是肯定的,我会使用一个数据结构(通常是哈希表)来实现这种魔法。我称这种技巧为“神奇查找”。
让我用一个例子来说清这个技巧。"
神奇的查找作者
让我们假设我们正在编写图书馆软件,我们有关于书籍及其作者的数据,分别存储在两个单独的数组中。具体来说,作者数组看起来是这样的:
authors = [
{"author_id" => 1, "name" => "Virginia Woolf"},
{"author_id" => 2, "name" => "Leo Tolstoy"},
{"author_id" => 3, "name" => "Dr. Seuss"},
{"author_id" => 4, "name" => "J. K. Rowling"},
{"author_id" => 5, "name" => "Mark Twain"}
]
正如您所见,这是一个哈希表的数组,每个哈希表包含作者的姓名和ID。
我们还有一个包含书籍数据的单独数组:
books = [
{"author_id" => 3, "title" => "Hop on Pop"},
{"author_id" => 1, "title" => "Mrs. Dalloway"},
{"author_id" => 4, "title" => "Harry Potter and the Sorcerer's Stone"},
{"author_id" => 1, "title" => "To the Lighthouse"},
{"author_id" => 2, "title" => "Anna Karenina"},
{"author_id" => 5, "title" => "The Adventures of Tom Sawyer"},
{"author_id" => 3, "title" => "The Cat in the Hat"},
{"author_id" => 2, "title" => "War and Peace"},
{"author_id" => 3, "title" => "Green Eggs and Ham"},
{"author_id" => 5, "title" => "The Adventures of Huckleberry Finn"}
]
与作者数组类似,书籍数组包含了许多哈希表。每个哈希表包含书名和作者的作者ID,这样我们就可以利用作者数组的数据来确定书籍的作者。例如,“Hop on Pop”的作者ID是3。这意味着“Hop on Pop”的作者是Dr. Seuss,因为他是作者数组中ID为3的作者。
现在,假设我们想要编写代码,将这些信息组合起来创建一个以下格式的数组:
books_with_authors = [
{"title" => "Hop on Pop", "author" => "Dr. Seuss"},
{"title" => "Mrs. Dalloway", "author" => "Virginia Woolf"},
{"title" => "Harry Potter and the Sorcerer's Stone", "author" => "J. K. Rowling"},
{"title" => "To the Lighthouse", "author" => "Virginia Woolf"},
{"title" => "Anna Karenina", "author" => "Leo Tolstoy"},
{"title" => "The Adventures of Tom Sawyer", "author" => "Mark Twain"},
{"title" => "The Cat in the Hat", "author" => "Dr. Seuss"},
{"title" => "War and Peace", "author" => "Leo Tolstoy"},
{"title" => "Green Eggs and Ham", "author" => "Dr. Seuss"},
{"title" => "The Adventures of Huckleberry Finn", "author" => "Mark Twain"}
]
要做到这一点,我们可能需要遍历书籍数组,并将每本书与其对应的作者连接起来。我们会如何具体操作呢?
一种解决方案可能是使用嵌套循环。外部循环将遍历每本书,而对于每本书,我们将运行一个内部循环,该循环将检查每个作者,直到找到具有相同ID的作者。以下是这种方法的Ruby实现:
def connect_books_with_authors(books, authors)
books_with_authors = []
books.each do |book|
authors.each do |author|
if book["author_id"] == author["author_id"]
books_with_authors << {title: book["title"], author: author["name"]}
end
end
end
return books_with_authors
end
在我们优化代码之前,我们需要满足先决条件,确定我们当前算法的大O。
该算法的时间复杂度为O(N * M),因为对于每本书的N,我们需要循环M个作者以找到书的作者。
现在,让我们看看是否能够更好地优化。
同样,我们需要做的第一件事是想出最佳可想象的大O。在这种情况下,我们肯定需要遍历所有的N本书,因此似乎不可能超越O(N)。由于O(N)是我能想到的不完全不可能的最快速度,我们将认为O(N)是我们最佳可想象的大O。
现在,我们准备使用新的“神奇查找”技术。为此,我会问自己本节开头提到的问题:“如果我能在O(1)的时间内神奇地找到所需的信息,我能让我的算法变得更快吗?”
让我们将这个技巧应用到我们的场景中。目前,我们运行一个外部循环,遍历所有的书籍。目前,对于每本书,我们运行一个内部循环,尝试在作者数组中找到书的作者ID。
但是如果我们有神奇的能力,可以在O(1)的时间内找到一个作者呢?也就是说,如果我们不必每次想查找作者时都遍历所有的作者,而是可以立即找到作者,那将极大地提高我们算法的速度,因为我们可能可以消除内部循环,并将代码的速度提升到令人瞩目的O(N)。
既然我们已经确定了这种神奇的查找能力可以帮助我们,下一步就是努力让这种神奇变为现实。
引进额外的数据结构
我们实现这种神奇查找能力的最简单方法之一是引入额外的数据结构到我们的代码中。我们将使用这个数据结构以特定的方式存储数据,以便快速查找这些数据。在许多情况下,哈希表是非常适合的数据结构,因为它具有O(1)的查找时间,就像您在《使用哈希表进行快速查找》中学到的那样。
当前,因为作者哈希表存储在一个数组中,要找到其中任何给定的author_id都将花费我们O(M)的步骤(M为作者的数量)。但如果我们将相同的信息存储在哈希表中,我们现在可以在O(1)的时间内实现“神奇”的查找每个作者。
下面是这个哈希表可能的样子:
author_hash_table = {
1 => "Virginia Woolf",
2 => "Leo Tolstoy",
3 => "Dr. Seuss",
4 => "J. K. Rowling",
5 => "Mark Twain"
}
在这个哈希表中,每个键是作者的ID,每个键的值是作者的姓名。
所以,让我们通过首先将作者数据移到这个哈希表中,然后再运行我们的循环遍历书籍来优化我们的算法:
def connect_books_with_authors(books, authors)
books_with_authors = []
author_hash_table = {}
# 将作者数据转换成作者哈希表:
authors.each do |author|
author_hash_table[author["author_id"]] = author["name"]
end
books.each do |book|
books_with_authors << {
"title" => book["title"],
"author" => author_hash_table[book["author_id"]]
}
end
return books_with_authors
end
在这个版本中,我们首先遍历作者数组,并使用这些数据创建author_hash_table
。这需要M步,其中M是作者的数量。
然后,我们遍历书籍列表,并使用author_hash_table
来在单步中“神奇地”查找每个作者。这个循环需要N步,其中N是书籍的数量。
这个优化后的算法总共需要O(N + M)的步骤,因为我们只需对N本书进行一次循环,对M个作者进行一次循环。这比起原始算法O(N * M)快了很多。
值得注意的是,通过创建额外的哈希表,我们使用了额外的O(M)空间,而我们最初的算法根本没有使用额外的空间。然而,如果为了速度而愿意牺牲内存,这是一种很好的优化。
我们通过首先梦想O(1)神奇查找能为我们带来的好处,然后通过使用哈希表将数据存储在易于查找的方式中,使这种魔力成为现实。
能够在O(1)时间内查找哈希表数据并不是什么新鲜事物,因为我们在《使用哈希表进行快速查找》中已经了解过了。我在这里分享的特定提示是不断地想象你可以对任何类型的数据执行O(1)查找,并注意这是否会加速您的代码。一旦您具有O(1)查找将如何帮助您的愿景,您就可以尝试使用哈希表或其他数据结构将这个梦想变成现实。
双数之和问题
让我们看另一个场景,我们可以从神奇的查找中受益的例子。这是我最喜欢的优化示例之一。
两数之和问题是一个众所周知的编程练习。任务是编写一个函数,接受一个数字数组,并根据数组中是否存在任意两个数的和等于10(或其他给定的数字),返回true或false。为简单起见,让我们假设数组中不会有重复的数字。假设我们的数组是:
[2, 0, 4, 1, 7, 9]
我们的函数将返回true,因为1和9相加等于10。如果数组是:
[2, 0, 4, 5, 3, 9]
我们将返回false。即使数字2、5和3的和为10,但我们特别需要两个数字的和为10。
首先想到的解决方法是使用嵌套循环来比较每个数字与其他每个数字,看它们是否相加为10。这是一个JavaScript实现:
function twoSum(array) {
for(let i = 0; i < array.length; i++) {
for(let j = 0; j < array.length; j++) {
if(i !== j && array[i] + array[j] === 10) {
return true;
}
}
}
return false;
}
和往常一样,在尝试优化之前,我们需要满足预备条件并确定我们代码的当前时间复杂度。
像典型的嵌套循环算法一样,这个函数的运行时间是O(N^2)。
接下来,为了看看我们的算法是否值得优化,我们需要确定最理想的时间复杂度是否会更好。
在这种情况下,似乎我们绝对必须至少访问数组中的每个数字一次。所以,我们不能超越O(N)。如果有人告诉我这个问题有一个O(N)的解决方案,我想我会相信他们。所以,让我们将O(N)作为我们最理想的时间复杂度。
现在,让我们问自己这个神奇查找的问题:“如果我能在O(1)时间内神奇地找到所需的信息,能让我的算法更快吗?”
有时候,在沿着当前的实现开始思考这个问题的同时,可以帮助我们更好地理解。让我们这样做。
让我们在示例数组[2, 0, 4, 1, 7, 9]中,心理上遍历我们的外部循环。这个循环从第一个数字开始,即数字2。
现在,当我们看到数字2时,我们可能想要查找的信息是什么呢?我们想要知道这个2是否可以与数组中的另一个数字相加得到10。
进一步思考,当看到数字2时,我想知道数组中是否存在一个8。如果我们能神奇地以O(1)进行查找,并知道数组中有一个8,我们就可以立即返回true。
让我们称8为2的配对数字,因为这两个数字加起来是10。
同样,当我们移动到数字0时,我们想要进行O(1)查找以找到它的配对数字—即数组中的10,以此类推。
通过这种方法,我们可以遍历数组仅一次,并在路上进行神奇的O(1)查找,以查看每个数字的配对数字是否存在于数组中。一旦我们找到任何数字的配对数字,我们就返回true;但是如果我们到达数组末尾而没有找到任何数值的配对数字,我们就返回false。
现在我们确定了我们将受益于这些神奇的O(1)查找,让我们尝试通过引入额外的数据结构来完成我们的魔术。同样,哈希表通常是进行神奇查找的默认选择,因为它具有O(1)的读取时间。(哈希表在提速算法中的使用频率之高真是不可思议。)
因为我们希望能够在O(1)时间内查找数组中的任何数字,我们将这些数字作为键存储在哈希表中。哈希表可能是这样的:
{2: true, 0: true, 4: true, 1: true, 7: true, 9: true}
我们可以使用任意项来作为值;让我们决定使用true。
现在我们可以在O(1)时间内查找数组中的任何数字,那么如何查找一个数字的配对数字呢?好的,我们注意到当我们迭代数字2时,我们知道配对数字应该是8。我们之所以知道这一点,是因为我们直觉地知道2 + 8 = 10。
基本上,我们可以通过从10中减去它来计算任何数字的配对数字。因为10 - 2 = 8,这意味着8是数字2的配对数字。
现在我们拥有了创建一个非常快的算法的所有要素:
function twoSum(array) {
let hashTable = {};
for(let i = 0; i < array.length; i++) {
// 检查哈希表是否包含一个键,当与当前数字相加时
// 可以得到10:
if(hashTable[10 - array[i]]) {
return true;
}
// 将每个数字作为键存储在哈希表中:
hashTable[array[i]] = true;
}
// 如果到达数组末尾而没有找到任何数字的配对数字,则返回false:
return false;
}
这个算法在数组中遍历每个数字。当我们访问每个数字时,我们检查哈希表是否包含一个键,它是当前数字的配对数字。我们通过计算10 - array[i]来确定这个数字(例如,如果array[i]是3,那么配对数字就是7,因为10 - 3 = 7)。
如果我们找到任何数字的配对数字,我们立即返回true,因为这意味着我们找到了两个数字,它们的和为10。此外,当我们遍历每个数字时,我们将数字作为键插入哈希表中。这是我们在遍历数组时如何填充哈希表的方法。
通过这种方法,我们将算法的速度大幅提升到了O(N)。我们通过在哈希表中存储所有数据元素来实现了这一点,以便在循环中进行O(1)的查找。
哈希表就像你的魔法棒一样,让你成为你注定要成为的编程巫师。(好吧,够了。)
识别模式
在代码优化和一般算法开发中,最有帮助的策略之一是找到问题本身的模式。通常,发现模式可以帮助你穿透问题的复杂性,从而开发出一个实际上相当简单的算法。
硬币游戏
这是一个很好的例子。有一个我称之为“硬币游戏”的游戏,两名玩家进行以下竞争。他们从一堆硬币开始,每个玩家可以选择从堆中拿走一枚或两枚硬币。拿到最后一枚硬币的玩家将输掉比赛。有趣,对吧?
事实证明,这不是一场随机游戏,通过正确的策略,你可以迫使你的对手拿到最后一枚硬币并输掉比赛。为了让这一点清楚,让我们从一些非常小的硬币堆开始,看看游戏的过程。
如果堆中只有一枚硬币,轮到当前玩家的那位将输掉,因为他们别无选择,只能拿走最后一枚硬币。如果剩下两枚硬币,轮到当前玩家的那位可以强行获胜。这是因为他们可以只拿走一枚硬币,从而迫使对手拿到最后一枚。
当剩下三枚硬币时,轮到当前玩家的那位也可以强行获胜,因为他们可以拿走两枚硬币,迫使对手拿到最后一枚硬币。
现在,当剩下四枚硬币时,轮到当前玩家的那位陷入了困境。如果他们拿走一枚硬币,对手得到三枚硬币,而我们之前已经确认,这会让对手强行获胜。同样地,如果当前玩家拿走两枚硬币,对手会剩下两枚硬币,这同样可以让对手强行获胜。
如果我们要编写一个函数来计算在给定数量硬币堆的情况下你是否能赢得游戏,我们应该采取什么样的方法呢?如果我们仔细考虑这个问题,我们可能会意识到,我们可以利用子问题来计算出任意数量硬币的准确结果。这使得自顶向下的递归成为解决这个问题的自然选择。
下面是一个Ruby实现的递归方法:
def game_winner(number_of_coins, current_player="you")
if number_of_coins <= 0
return current_player
end
if current_player == "you"
next_player = "them"
elsif current_player == "them"
next_player = "you"
end
if game_winner(number_of_coins - 1, next_player) == current_player || game_winner(number_of_coins - 2, next_player) == current_player
return current_player
else
return next_player
end
end
这个game_winner
函数接受一个硬币数量和当前轮到的玩家(“you"或"them”)作为参数。然后,函数返回游戏的赢家,可能是"you"或"them"。当函数首次被调用时,current_player
是"you"。
我们定义了当current_player
拿到0枚或更少的硬币时,作为基本情况。这意味着另一个玩家拿到了最后一枚硬币,而current_player
默认赢得了比赛。
然后,我们定义了一个next_player
变量,用于跟踪接下来轮到哪个玩家。
接着,进行递归操作。我们在比当前堆小一枚和两枚的硬币堆上递归调用game_winner
函数,并查看接下来的玩家在这些场景中是赢还是输。如果接下来的玩家在这两个场景中都输了,那么当前玩家就会赢。
这个算法并不简单,但我们成功实现了它。现在,让我们看看能否对其进行优化。
为了满足我们的先决条件,首先需要弄清楚我们算法的当前速度。你可能已经注意到,这个函数进行了多次递归调用。如果你脑海中有警铃响起,那是有充分理由的。这个函数的时间复杂度高达 O(2^N),这个速度可能非常慢。
现在,我们可以通过使用你在动态规划中学到的记忆化技术来改进它,这可能将速度提高到 O(N),其中 N 是起始硬币数量。这是一个巨大的改进。
但让我们看看能否进一步提高算法的速度。
为了确定是否可以进一步优化我们的算法,我们需要问自己我们认为最好的大 O 是多少。
因为 N 只是一个单独的数字,我可以想象我们可以设计一个只需要 O(1) 时间的算法。由于我们实际上不需要操作数组中的 N 个项目之类的事情,如果有人告诉我他们找到了一个只需要 O(1) 时间的硬币游戏算法,我会相信他们。因此,让我们努力达到 O(1)。
但我们该怎么做呢?这就是找到模式的地方可以帮助的地方。
生成案例
虽然每个问题都有独特的模式,但我找到了一种寻找模式的技巧,可以帮助解决所有问题。那就是生成大量示例。这意味着我们应该拿一堆示例输入,计算它们各自的输出,看看是否能够发现模式。
让我们把这个方法应用到我们的情况上。
如果我们列出硬币堆大小从 1 到 10 时谁赢,我们得到这个表格:
硬币数量 获胜者
1 他们
2 你
3 你
4 他们
5 你
6 你
7 他们
8 你
9 你
10 他们
用这种方式列出来后,模式变得清晰了。基本上,从1个硬币开始,每隔三个数字对手就会获胜。否则,你是获胜者。
因此,如果我们取硬币数量并减去1,每个“他们”的硬币最终会得到一个可以被3整除的数字。在这一点上,我们可以根据单个除法计算来确定谁将获胜:
def game_winner(number_of_coins):
if (number_of_coins - 1) % 3 == 0:
return "them"
else:
return "you"
这段代码表示,如果减去1后的硬币数量可以被3整除,获胜者是“他们”。否则,“你”将是获胜者。
因为这个算法只涉及一个数学操作,所以它在时间和空间上都是 O(1)。而且它也简单得多!这真是一个真正的三赢局面。
通过生成许多硬币堆的示例(作为输入),并观察谁会赢得游戏(作为输出),我们能够找到硬币游戏运作方式的模式。然后,我们可以利用这个模式来直奔问题的核心,并将一个缓慢的算法转变为瞬间完成的算法。
求和交换问题
这里有一个例子,我们可以同时使用模式识别和奇妙的查找来优化一个算法。
接下来的问题,被称为“求和交换”问题,是这样的:
我们想要编写一个函数,接受两个整数数组作为输入。比如,假设我们的数组是:
目前,array_1 中的数字加起来是 20,而 array_2 中的数字加起来是 18。
我们的函数需要找到一个来自每个数组的数,交换它们使得两个数组的和相等。
在这个例子中,如果我们交换 array_1 中的 2 和 array_2 中的 1,我们将得到:
这样两个数组现在的和都是相同的,即 19。
为了保持简单,我们的函数不会实际执行交换,而是返回需要交换的两个索引。我们可以将这些作为包含两个索引的数组返回。所以,在这个例子中,我们交换了 array_1 的索引 2 和 array_2 的索引 0,所以我们将返回一个数组 [2, 0]。如果没有可能的交换使得两个数组相等,我们将返回 nil。
我们可以编写这个算法的一种方式是使用嵌套循环。也就是说,当我们外部循环指向 array_1 中的每个数字时,内部循环可以迭代 array_2 中的每个数字,并测试如果我们交换了两个数字后每个数组的和。
要开始优化这个算法,我们首先需要满足我们知道当前算法的大 O 的先决条件。
因为我们的嵌套循环方法对于第一个数组的每个 N 个数字,都要访问第二个数组的 M 个数字,所以这个算法的时间复杂度是 O(N * M)。(我提到了 N 和 M,因为这两个数组可能有不同的大小。)
我们能做得更好吗?为了找出答案,让我们确定一下我们认为最理想的大 O 是多少。
看起来,我们至少需要访问两个数组中的每个数字一次,因为我们需要知道所有数字是什么。但有可能这可能就是我们需要做的全部。如果是这样的话,时间复杂度可能是 O(N + M)。让我们将这个作为我们最理想的大 O,并朝着这个目标努力。
接下来,我们需要尝试挖掘问题中隐藏的任何模式。再次弄清模式的最佳技巧是想出许多示例,并在其中寻找模式。
所以,让我们看一些不同的示例,其中交换数字会导致两个数组具有相等的和:
在观察这些示例时,一些模式开始显现。其中一些模式可能看起来很明显,但让我们仍然来看看它们。
一个模式是,要实现相等的和,较大的数组需要与较小数组中较小的数字进行交换。
第二个模式是,通过单次交换,每个数组的和变化量相同。例如,当我们将一个 7 与一个 4 交换时,一个数组的和减少了 3,而另一个数组的和增加了 3。
第三个有趣的模式是,交换总是导致两个数组的和恰好落在两个数组和的中间位置。
在第一个案例中,例如,array_1 是 18,array_2 是 12。当进行正确的交换时,两个数组最终落在 15,恰好处于 18 和 12 之间的中间位置。
当我们进一步思考时,这第三个模式是其他模式的逻辑延伸。由于交换导致两个数组的和同时移动相同的量,使它们的和相等的唯一方法就是相遇在中间位置。基于此,如果我们知道两个数组的和,我们应该能够查看其中一个数组中的任何数字,并计算应该与之交换的数字。
让我们再次看看这个例子:
我们知道,要成功进行交换,我们需要两个数组的和落在中间位置。18 和 12 的确切中间位置是 15。
让我们看一看来自 array_1 的不同数字,找出我们想要与之交换的数字。我们可以称之为它的“对应数字”。让我们从 array_1 的第一个数字开始,这个数字是 5。
我们想要与 5 进行交换的数字是什么?嗯,我们知道我们想让 array_1 减少 3,array_2 增加 3,所以我们需要将 5 与一个数字 2 进行交换。恰好 array_2 中没有 2,所以无法成功将 5 与 array_2 中的任何数字进行交换。
如果我们看 array_1 的下一个数字,是 3。我们必须将其与 array_2 中的 0 进行交换才能使两个和相等。但遗憾的是,array_2 中没有 0。
不过,array_1 中的最后一个数字是 7。我们可以计算出,我们想要将 7 与 4 进行交换,使两个和都落在 15。幸运的是,array_2 中有一个 4,所以我们可以进行成功的交换。
那么,我们如何用代码来表达这个模式呢?
首先,我们可以通过以下计算确定一个数组和需要移动的量:
shift_amount = (sum_1 - sum_2) / 2
这里,sum_1 是 array_1 的和,sum_2 是 array_2 的和。如果 sum_1 是 18,sum_2 是 12,那么我们得到的差值是 6。然后我们将其除以 2 来确定每个数组需要移动的量,这就是 shift_amount。
在这种情况下,shift_amount 是 3,表明 array_2 需要增加 3 才能达到目标和。(同样,array_1 需要减少 3。)
因此,我们可以首先计算两个数组的和来构建我们的算法。然后我们可以遍历其中一个数组中的所有数字,并在另一个数组中寻找对应数字。
举例来说,如果我们遍历 array_2 中的每个数字,当前数字必须与其对应数字交换,这个对应数字就是当前数字加上 shift_amount。例如,如果当前数字是 4,要找到它的对应数字,我们将 shift_amount 加到它上面(3),得到 7。这意味着我们需要在 array_1 中找到一个 7 来与当前数字交换。
因此,我们已经发现,我们可以查看任何一个数组中的数字,准确知道它在另一个数组中对应的数字。但这有什么帮助呢?我们是否仍然需要使用嵌套循环,拥有一个时间复杂度为 O(N * M) 的算法?也就是说,对于一个数组中的每个数字,我们需要在整个另一个数组中搜索其对应数字。
这就是我们可以利用神奇的查找方法的地方,我们可以问自己,“如果我可以以 O(1) 的时间神奇地找到所需的信息,那么我能让我的算法变得更快吗?”
确实,如果我们能够在 O(1) 的时间内找到另一个数组中数字的对应数字,我们的算法将会快得多。而我们可以通过使用传统的哈希表技术来实现这些快速查找。
如果我们首先将一个数组中的数字存储在哈希表中,那么当我们遍历另一个数组时,我们可以在 O(1) 的时间内立即找到其中的任何数字。
下面是完整的代码:
def sum_swap(array_1, array_2)
# 哈希表来存储第一个数组的值:
hash_table = {}
sum_1 = 0
sum_2 = 0
# 获取第一个数组的和,同时将其值存储在哈希表中,连同索引一起
array_1.each_with_index do |num, index|
sum_1 += num
hash_table[num] = index
end
# 获取第二个数组的和:
array_2.each do |num|
sum_2 += num
end
# 计算第二个数组中的数字需要移动的量:
shift_amount = (sum_1 - sum_2) / 2
# 遍历第二个数组中的每个数字:
array_2.each_with_index do |num, index|
# 检查哈希表中是否存在第一个数组中对应的数字,
# 这个数字被计算为当前数字加上它需要移动的量:
if hash_table[num + shift_amount]
return [hash_table[num + shift_amount], index]
end
end
return nil
end
这种方法比我们原来的 O(N * M) 的算法要快得多。如果我们将 array_1 视为 N,array_2 视为 M,我们可以说这个算法的时间复杂度是 O(N + M)。虽然我们在技术上是两次遍历了 array_2,理论上是 2M,但因为我们去掉了常数项,所以它变成了 M。
这种方法会占用额外的 O(N) 空间,因为我们将所有 N 个数字从 array_1 复制到哈希表中。再一次地,我们在空间和时间之间做了取舍,但如果速度是我们的首要考虑因素,这将是一个巨大的胜利。
无论如何,这又是一个例子,发现模式让我们直奔问题的核心,并开发了一个简单而快速的解决方案。
贪婪算法
下面这个策略可以加速一些最顽固的算法。它并不适用于所有情况,但在适用时,它可能会改变游戏规则。
让我们谈谈编写贪婪算法。
这听起来可能是一个奇怪的术语,但其实它是什么意思。贪婪算法是指在每一步中,选择在那一时刻看起来最好的选项。通过一个基本的例子,这将会变得清晰起来。
数组最大值
让我们编写一个在数组中找到最大数的算法。我们可以使用嵌套循环,将每个数字与数组中的其他每个数字进行比较。当我们找到一个数字大于数组中的每个其他数字时,这意味着我们找到了数组中的最大数。就像这种典型的算法一样,这种方法需要 O(N²) 的时间。
另一种方法是对数组进行升序排序,并返回数组的最后一个值。如果我们使用像快速排序这样的快速排序算法,时间复杂度将是 O(N log N)。
还有第三种选择,就是贪婪算法。这里是代码:
def max(array)
greatest_number = array[0]
array.each do |number|
if number > greatest_number
greatest_number = number
end
end
return greatest_number
end
正如你所见,我们函数的第一行假设数组中的第一个数字是 greatest_number。这是一个“贪婪”的假设。也就是说,我们声明第一个数字是 greatest_number,因为它是我们目前为止遇到的最大数字。当然,它也是我们目前为止遇到的唯一数字!但这就是贪婪算法的工作原理——根据当时可用的信息选择看似最佳的选项。
接下来,我们遍历数组中的所有数字。当我们找到任何一个大于 greatest_number 的数字时,我们将这个新数字设为 greatest_number。同样,这里也是贪婪的;每一步都基于我们在那个时刻所知道的信息选择最佳的选项。
我们就像是一个在糖果店里抓住第一个看到的糖果的孩子,但一旦看到一个更大的糖果,我们就放下第一个,抓起更大的那个。
然而,这种看似天真的贪婪实际上是有效的。当函数完成时,我们的 greatest_number 确实是整个数组中的最大数。而且,虽然贪婪在社会环境中并不是美德,但对于算法的速度来说,它却能发挥奇迹。这个算法只需要 O(N) 的时间,因为我们只对数组中的每个数字进行一次操作。
最大子段和
最大子段和
让我们看另一个贪婪算法的例子。
我们将编写一个接受数字数组并返回数组中任何“子段”所能计算得到的最大和的函数。
我是这么说的。让我们拿以下数组为例:
[3, -4, 4, -3, 5, -9]
如果我们计算这个数组中所有数字的总和,我们会得到 -4。
但我们也可以计算数组的子段的和:
当我提到子段时,我指的是连续的子段。也就是说,一个子段是数组中连续的一系列数字。
以下不是一个连续的子段,因为数字并不是连续的:
我们的任务是在数组中找到可以计算得到的任何子段的最大和。在我们的例子中,最大和是 6,来自以下子段:
为了讨论更简单,让我们假设数组至少包含一个正数。
现在,我们如何编写代码来计算最大子段和呢?
有一种方法是计算数组中每个子段的和并选择最大的那个。然而,对于数组中的 N 个项目,大约有 N² / 2 个子段,所以仅生成不同的子段就需要 O(N²) 的时间。
再次,让我们从梦想中寻找最佳的时间复杂度。我们绝对需要至少检查每个数字一次,所以我们无法超越 O(N)。因此,让我们设定目标为 O(N)。
乍一看,O(N) 看起来超出了我们的能力范围。如何通过一次数组迭代来计算多个子段的和呢?
让我们看看如果我们有些贪心会发生什么……
在这种情况下,一个贪婪算法会试图在我们迭代数组时,在每一步“抓住”最大的和。以下是当我们迭代前面的示例数组时可能看起来的情况。
从数组的开头开始,我们遇到了一个 3。按照完美的贪心方式,我们会说我们的最大和是 3:
接下来,我们到达了 -4。当我们将这个数加到之前的 3 上时,我们得到了当前和 -1。所以,3 仍然是我们的最大和:
然后我们遇到了 4。如果我们把这个加到当前和上,我们得到了 3:
到目前为止,3 仍然是最大和。
我们接下来遇到的数字是 -3。这使得我们当前的和为 0:
尽管 0 是我们当前的和,但 3 仍然是我们的最大和。
接下来,我们到达了 5。这使得我们当前的和为 5。在贪心的情况下,我们会宣称这是最大的和,因为这是我们迄今为止遇到的最大和:
然后我们到达了最后一个数字,也就是 -9。这使得我们的当前数字降至 -4:
到达数组末尾时,我们的最大和是 5。所以,如果我们按照这种纯粹的贪心方法,似乎我们的算法应该返回 5。
然而,5 实际上并不是最大的子段和。在数组中有一个子段可以得到 6 的和:
我们算法的问题在于,我们只计算了基于总是以数组中的第一个数字开头的子段的最大和。但是,还有其他以数组中较后的数字开头的子段。我们没有考虑到这些情况。
因此,我们的贪心算法并没有像我们希望的那样得到预期的结果。
但我们不应该放弃!通常情况下,我们需要对贪心算法进行一些调整才能使其发挥作用。
让我们看看是否找到一个模式可以有所帮助。(通常情况下都是如此。)正如我们之前所见,找到模式的最佳方法是生成大量的例子。所以,让我们想出一些数组及其最大子段和的例子,看看我们是否发现了有趣的东西:
在分析这些情况时,出现了一个有趣的问题:为什么有些情况下,最大和来自于数组开头的子段,而在其他情况下则不是呢?
在观察这些情况时,我们可以看到,当最大的子段不是从开头开始时,那是因为一个负数打断了连续的和:
也就是说,最大的子段本来会来自于数组的开头,但是一个负数打破了这个连续性,最大的子段必须从数组中较后的位置开始。
但等一下。在某些情况下,最大的子段包括一个负数,而这个负数并没有打断连续性:
那么,区别在哪里呢?
这个模式是:如果负数导致前面子段的和降到了负数,就会打破连续性。但是如果负数只是降低了当前子段的和,并且和仍然是正数,就不会打破连续性。
如果我们仔细想想,这是有道理的。如果在遍历数组时,当前子段的和变成了小于 0 的数,最好就重置一下。
当前和降至 0。否则,当前的负数和将影响我们试图找到的最大和。
因此,让我们利用这个洞见来调整我们的贪心算法。
再次,让我们从 3 开始。当前最大和是 3:
接下来,我们遇到了 -4。这会使得我们当前的和变成 -1:
由于我们正在寻找具有最大和的子段,而我们当前的和是一个负数,我们需要在继续下一个数字之前将当前的和重置为 0:
我们也会从下一个数字开始一个全新的子段。
同样的理由是,如果下一个数字是正数,我们可能会直接从那里开始下一个子段,而不让当前的负数影响和。相反,我们会通过将当前和设为 0 来进行重置,并将下一个数字视为新子段的开始。
所以,让我们继续。
现在,我们到达了 4。同样,这是一个新子段的开始,所以当前的和是 4,这也成为我们迄今为止见过的最大和:
接下来,我们遇到了 -3。当前的和变成了 1:
然后,我们遇到了一个 5。这使得当前的和变成了 6,这也是最大的和:
最后,我们到达了 -9。这会使当前的和变成 -4,此时我们将其重置为 0。不过,我们也到达了数组的末尾,可以得出最大的和是 6。事实上,这是正确的结果。
以下是这种方法的代码:
def max_sum(array)
current_sum = 0
greatest_sum = 0
array.each do |num|
# 如果当前和是负数,将当前和重置为零:
if current_sum + num < 0
current_sum = 0
else
current_sum += num
# 如果当前和大于我们迄今为止遇到的最大和,则贪心地假设当前和就是最大和:
greatest_sum = current_sum if current_sum > greatest_sum
end
end
return greatest_sum
end
使用这种贪心算法,我们仅需一次循环遍历数组就能以 O(N) 时间解决这个棘手的问题。这比最初的 O(N²) 方法有了很大的改进。在空间方面,这个算法是 O(1),因为我们没有生成任何额外的数据。
尽管发现了模式帮助我们找到了准确的解决方案,但是通过采用贪心的思维模式,我们最初就知道我们在寻找什么样的模式。
贪心股票预测
让我们再看一个贪心算法。
假设我们正在编写预测股票的金融软件。我们现在正在处理的特定算法是寻找给定股票的正向趋势。
具体来说,我们正在编写一个接受股票价格数组的函数,确定是否存在三个价格形成的上升趋势。
举例来说,考虑下面这个股票价格数组,它代表了某股票随时间的价格变化:
[22, 25, 21, 18, 19.6, 17, 16, 20.5]
虽然一开始可能难以发现,但存在三个价格形成了一个上升趋势:
也就是说,当我们从左向右移动时,存在三个价格,其中一个“右侧”价格大于一个“中间”价格,而这个“中间”价格又大于一个“左侧”价格。
另一方面,以下数组则没有包含三个点的上升趋势:
[50, 51.25, 48.4, 49, 47.2, 48, 46.9]
如果数组包含三个价格的上升趋势,我们的函数应返回 true,如果没有则返回 false。
那么,我们该如何做呢?
一个方法是使用三个嵌套循环。也就是说,第一个循环遍历每个股票价格,第二个循环遍历随后的所有股票价格。对于第二个循环的每一轮,第三个嵌套循环检查随后的第二个价格。当我们指向每组三个股票价格时,我们检查它们是否按升序排列。只要我们找到这样的一组,就返回 true。但如果我们完成了循环而没有找到任何这样的趋势,则返回 false。
这个算法的时间复杂度是 O(N3)。这相当慢!有没有办法优化它呢?让我们首先考虑最理想的大 O 表示法。我们肯定需要检查每个股价来找到趋势,所以我们知道我们的算法不可能比 O(N) 更快。我们来看看是否能优化到这样的速度。
再次,是时候变得贪心了。为了将贪心的思维应用到我们的情况中,我们想要一直“抓住”我们认为是三个价格上升趋势中最低点的价格。如果我们能用同样的贪心方法不断“抓住”我们认为是这个趋势的中间和最高点,那就更好了。
我们要做的是:
- 假设数组中的第一个价格是三个价格上升趋势中的最低点。
- 至于中间价格,我们将其初始化为一个保证大于数组中最高股价的数字。为此,我们将其设置为无穷大。(许多编程语言支持无穷大的概念。)这一步骤可能一开始看起来最不直观,但你很快就会明白为什么我们需要这样做。
- 然后,我们将遍历整个数组,按照以下步骤进行:
- 如果当前价格低于迄今为止遇到的最低价格,那么这个价格就成为新的最低价格。
- 如果当前价格高于最低价格,但低于中间价格,则将中间价格更新为当前价格。
- 如果当前价格高于最低和中间价格,这意味着我们找到了三个价格上升的趋势!
让我们看个例子。首先,我们将从这组股价数组开始:
我们从数组中的5开始迭代。我们纯粹地贪心地开始,并假设这个5是三个价格趋势中的最小价格,如下图数组所示。
接下来,我们来到了数字 2。因为 2 小于 5,我们更加贪心地假设 2 现在是趋势中的最低价:
接着,我们到达数组中的下一个数字,是 8。这比我们的最低点要高,所以我们保持最低点为 2。但是,它小于当前的中间价格(无穷大),所以我们贪心地将 8 分配为我们三个价格趋势中的中间点:
接着,我们到了数字 4。这比 2 要高,所以我们继续假设 2 是趋势中的最低点。但是因为 4 小于 8,我们将 4 设为我们的新中间点,而不是 8。这也是出于贪心,通过降低中间点,我们增加了后面找到更高价格、形成我们正在寻找的趋势的机会。所以,4 是我们的新中间点:
接下来数组中的数字是 3。我们将我们的最低价格保持在 2,因为 3 大于它。但是,我们将 3 设为我们的新中间点,因为它小于 4:
最后,我们到达了数字 7,这是数组中的最后一个值。因为 7 大于我们的中间价格(为 3),这意味着数组包含一个上升的三点趋势,因此我们的函数可以返回 true:
请注意,数组中存在两个这样的趋势。有 2-3-7,还有 2-4-7。但最终这对我们来说并不重要,因为我们只是尝试确定这个数组是否包含任何上升的趋势,所以找到一个实例足以返回 true。
这是该算法的实现:
def increasing_triplet?(array)
lowest_price = array[0]
middle_price = Float::INFINITY
array.each do |price|
if price <= lowest_price
lowest_price = price
# if current price is higher than lowest price
# but lower than middle price:
elsif price <= middle_price
middle_price = price
# if the current price is higher than the middle price:
else
return true
end
end
return false
end
有一个值得指出的反直觉的方面。特别是,在某些情况下,这个算法似乎不会起作用,但实际上它确实起作用。让我们看看这种情况:
我们来看一下当我们将这个算法应用于这个数组时会发生什么。
首先,8成为我们的最低点:
然后,9成为我们的中间点:
接下来,我们遇到了7。因为这个数字低于我们的最低点,我们将最低点更新为7:
然后我们到达了10:
因为10大于当前的中间点(9),我们的函数返回true。现在,这是正确的响应,因为我们的数组确实包含了8-9-10的趋势。然而,当我们的函数完成时,我们的最低点变量实际上指向7。但是7不是上升趋势的一部分!
尽管如此,我们的函数仍然返回了正确的响应。这是因为我们的函数只需要到达一个比中间点更高的数字。因为中间点只有在我们找到一个较低点之后才确定,所以一旦我们到达比中间点更高的数字,这仍然意味着数组中存在一个上升趋势。这是真实的,即使我们最终将较低点覆盖为稍后的其他数字。
无论如何,我们的贪婪方法取得了成功,因为我们只对数组进行了一次迭代。这是一个令人惊人的改进,因为我们将一个运行时间为O(N3)的算法变成了O(N)的算法。
当然,贪婪方法并不总是有效的。但在优化算法时,它是另一种可以尝试的工具。
改变数据结构
另一个有用的优化技巧是设想一下,如果我们将给定的数据存储在另一种数据结构中会发生什么情况。
例如,我们可能正在解决一个问题,其中数据以数组的形式提供给我们。然而,将同样的数据重新构想为哈希表、树形结构或其他数据结构有时可以揭示巧妙的优化机会。
我们之前使用哈希表进行了神奇的查找技巧,这是这种情况的一个具体例子。然而,我们即将看到改变数据结构对其他情景也可能会很有用。
字谜检查器
下面是一个例子。假设我们正在编写一个函数来确定两个给定的字符串是否是彼此的字谜。我们在《Anagram Generation》(第177页)中遇到过一个字谜函数,但在那里,我们处理了一个生成字符串的所有字谜的函数。在这里,我们只是要将两个字符串进行比较。如果它们是彼此的字谜,我们将返回true,否则返回false。
实际上,我们可以使用生成字谜的函数来解决这个问题。也就是说,我们可以产生第一个字符串的所有字谜,并查看第二个字符串是否与这些字谜中的任何一个匹配。然而,由于对于字符串中的N个字符,始终会有N!个字谜,我们的算法将至少需要O(N!)时间。这是非常慢的。
你知道该怎么做。在我们继续优化代码之前,我们需要想出我们能想象到的最好的大O。
现在,我们肯定需要至少访问每个字符串的每个字符一次。由于字符串的大小可能不同,只需一次性地处理每个字符将是O(N + M)。我无法想象更快的速度来完成手头的任务,这就是我们的目标。
让我们朝这个方向努力。
第二种可能的方法是运行嵌套循环来比较这两个字符串。具体来说,外部循环迭代第一个字符串中的每个字符,我们将该字符与第二个字符串的每个字符进行比较。每次找到匹配时,我们从第二个字符串中删除一个字符。这里的想法是,如果第一个字符串的每个字符也存在于第二个字符串中,那么当我们完成外部循环时,我们会删除第二个字符串的每个字符。
因此,如果在我们完成循环时,第二个字符串仍然有剩余字符,这意味着这两个字符串不是字谜。而且,如果我们仍在遍历第一个字符串,但我们已经删除了整个第二个字符串,这也意味着这两个字符串不是字谜。但是,如果我们到达循环的结尾,第二个字符串已经被完全删除,我们可以得出结论,这两个字符串确实是字谜。
以下是这个算法的Python实现:
def areAnagrams(firstString, secondString):
# Convert secondString into an array so we can delete characters from it,
# as strings are immutable in Python:
secondStringArray = list(secondString)
for i in range(0, len(firstString)):
# If we're still iterating through the firstString, but the
# secondStringArray is already empty:
if len(secondStringArray) == 0:
return False
for j in range(0, len(secondStringArray)):
# If we find the same character in both the firstString
# and secondStringArray:
if firstString[i] == secondStringArray[j]:
# Delete the character from the second array and
# go back to the outer loop:
del secondStringArray[j]
break
# The two strings are only anagrams if the secondStringArray
# has no characters remaining by the time we're done
# iterating over the firstString:
return len(secondStringArray) == 0
现在,恰好的是,在循环遍历数组时删除数组中的项目可能会导致错误。如果你处理不当,就像在你坐着的树枝上锯木头一样。但即使我们已经正确处理了这个问题,我们的算法的运行时间为O(N * M)。这比O(N!)要快得多,但比我们所追求的O(N + M)要慢得多。
一个更快的方法是对这两个字符串进行排序。如果对这两个字符串进行排序后它们完全相同,那么它们就是字谜;否则,它们不是。
这种方法对每个字符串使用快速排序算法(如快速排序)都将花费 O(N log N) 的时间。由于我们可能有两个不同长度的字符串,所以总时间复杂度为 O(N log N + M log M)。这比 O(N * M) 要好得多,但让我们不要停在这里 —— 记得,我们的目标是 O(N + M)。
这就是使用替代数据结构可能极为有帮助的地方。虽然我们正在处理字符串,但让我们想象一下,如果我们将字符串数据存储在其他类型的数据结构中会怎样。
我们可以将字符串存储为单个字符的数组。然而,这对我们并没有帮助。
接下来,让我们把字符串想象成一个哈希表。这会是什么样子呢?一个可能性是创建一个哈希表,其中每个字符是一个键,值是该字符在单词中出现的次数。例如,字符串 “balloon” 将变成:
{"b" => 1, "a" => 1, "l" => 2, "o" => 2, "n" => 1}
这个哈希表表示该字符串有一个 “b”,一个 “a”,两个 “l”,两个 “o” 和一个 “n”。
现在,这并不能告诉我们关于字符串的全部信息。换句话说,从哈希表中我们无法得知字符串中字符的顺序。所以,在这方面有一些数据丢失。
然而,这种数据丢失正是我们需要的,以帮助我们确定两个字符串是否是同字母异序词。也就是说,如果两个字符串具有相同数量的每个字符,那么无论顺序如何,它们都会是同字母异序词。比如,单词 “rattles”,“startle” 和 “starlet”。它们都有两个 “t”,一个 “a”,一个 “l”,一个 “e” 和一个 “s” —— 这使它们成为了同字母异序词,并且可以轻松地重新排列成彼此。
现在我们可以编写一个算法,将每个字符串转换为一个哈希表,统计每种字符的数量。一旦我们将这两个字符串转换为两个哈希表,剩下的就是比较这两个哈希表。如果它们相同,那么这两个字符串就是同字母异序词。
以下是这种方法的实现:
def areAnagrams(firstString, secondString):
firstWordHashTable = {}
secondWordHashTable = {}
# 从第一个字符串创建哈希表:
for char in firstString:
if firstWordHashTable.get(char):
firstWordHashTable[char] += 1
else:
firstWordHashTable[char] = 1
# 从第二个字符串创建哈希表:
for char in secondString:
if secondWordHashTable.get(char):
secondWordHashTable[char] += 1
else:
secondWordHashTable[char] = 1
# 当且仅当两个哈希表相同时,这两个字符串才是同字母异序词:
return firstWordHashTable == secondWordHashTable
在这个算法中,我们仅需对来自两个字符串的每个字符进行一次迭代,即 N + M 步。检查两个哈希表是否相等可能需要另外的 N + M 步。在像 JavaScript 这样的语言中,我们只能通过手动迭代两个哈希表的每个键值对来检查哈希表的相等性。但是,这仍然只是 2(N + M) 步,简化为 O(N + M)。这比我们以前的任何方法都要快得多。
公平地说,我们使用这些哈希表的创建会占用一些额外的空间。如果我们在原地进行排序,那么我们之前的建议是不需要额外空间的。但是如果我们追求的是速度,那么我们无法打败哈希表的方法,因为我们只需一次遍历字符串中的每个字符。
通过将字符串转换为另一种数据结构(在本例中为哈希表),我们能够以一种访问原始数据的方式来优化我们的算法,从而使其速度快得惊人。
并不总是很明显应该使用什么新的数据结构,所以想象当前数据如果转换成各种格式可能会有所帮助,并查看是否能揭示出任何优化。话虽如此,哈希表往往是一个很好的选择,所以这是一个不错的起点。
组合排序
这里有另一个例子,展示了如何改变数据结构可以让我们优化代码。假设我们有一个包含多个不同值的数组,我们想要重新排列数据,让相同的值归类在一起。但是,我们并不一定关心分组的顺序。
举个例子,假设我们有以下数组:
["a", "c", "d", "b", "b", "c", "a", "d", "c", "b", "a", "d"]
我们的目标是将其排序成分组,如下所示:
["c", "c", "c", "a", "a", "a", "d", "d", "d", "b", "b", "b"]
再次强调,我们不关心分组的顺序,所以下面的结果也是可以接受的:
["d", "d", "d", "c", "c", "c", "a", "a", "a", "b", "b", "b"]
["b", "b", "b", "c", "c", "c", "a", "a", "a", "d", "d", "d"]
现在,任何经典的排序算法都可以完成我们的任务,因为我们最终会得到以下结果:
["a", "a", "a", "b", "b", "b", "c", "c", "c", "d", "d", "d"]
正如你所知,最快的排序算法的时间复杂度为 O(N log N)。但我们能不能做得更好呢?
让我们首先来设想一下最好的时间复杂度。由于我们知道排序算法的时间复杂度不可能快于 O(N log N),要想在更短的时间内完成排序可能有些难度。
但是,因为我们并不需要精确排序,如果有人告诉我我们的任务可以在 O(N) 时间内完成,我可能会相信他们。毕竟,我们至少需要访问每个值一次,所以我们的目标是 O(N)。
让我们运用我们之前讨论过的技巧,想象一下我们的数据以另一种数据结构的形式存在。
我们可以从哈希表开始。如果我们将字符串数组表示成一个哈希表会是什么样子呢?
如果我们采用与刚才同字母异序词类似的方法,我们可以这样表示我们的数组:
{"a" => 3, "c" => 3, "d" => 3, "b" => 3}
和之前的例子一样,这样做会有一些数据丢失。也就是说,我们无法将这个哈希表转换回原始数组,因为我们无法知道所有字符串的原始顺序。
然而,对于我们的分组目的来说,这种数据丢失并不重要。实际上,这个哈希表包含了我们需要创建所需分组数组的所有数据。
具体来说,我们可以遍历哈希表中的每个键值对,并使用这些数据来填充一个包含正确数量每个字符串的数组。
以下是这种方法的代码:
def group_array(array)
hash_table = {}
new_array = []
# 存储每个字符串的计数到哈希表中:
array.each do |value|
if hash_table[value]
hash_table[value] += 1
else
hash_table[value] = 1
end
end
# 遍历哈希表,根据每个字符串的数量,向新数组中添加正确数量的每个字符串:
hash_table.each do |key, count|
count.times do
new_array << key
end
end
return new_array
end
我们的 group_array
函数接受一个数组,然后首先创建一个空的 hash_table
和一个空的 new_array
。
我们首先收集每个字符串的计数并将它们存储在哈希表中:
array.each do |value|
if hash_table[value]
hash_table[value] += 1
else
hash_table[value] = 1
end
end
这段代码创建了如下的哈希表:
{"a" => 3, "c" => 3, "d" => 3, "b" => 3}
然后,我们继续遍历哈希表中的每个键值对,并使用这些数据填充 new_array
:
hash_table.each do |key, count|
count.times do
new_array << key
end
end
也就是说,当我们遍历到键值对 “a” => 3 时,我们向 new_array
中添加了三个 “a”。当遍历到 “c” => 3 时,我们向 new_array
中添加了三个 “c”,依此类推。在完成遍历后,new_array
将包含所有按组组织的字符串。
这种算法仅需要 O(N) 的时间,相较于排序所需的 O(N log N) 时间,这是一个重大的优化。虽然我们使用了额外的哈希表和 new_array
占用了 O(N) 的空间,但我们也可以选择覆盖原始数组以节省额外的内存空间。不过,哈希表占用的空间在最坏情况下仍为 O(N),即数组中的每个字符串都是不同的情况。
总的来说,如果我们追求速度,我们已经达到了我们想象中的最佳时间复杂度,这是一个了不起的成功。
总结
这里介绍的技巧可以帮助优化您的代码。再次强调,您始终要先确定当前的时间复杂度(Big O)以及最理想的时间复杂度。之后,您就可以使用其他可用的技巧了。您会发现,有些技巧在某些情况下比其他技巧更有效,但在特定情况下思考它们是否适合当前任务是非常值得的。
通过经验,您会提升优化能力,可能还会发展出自己的额外技巧!
结束语
您在这段旅程中学到了很多。
您学会了算法设计和数据结构的正确选择如何极大地影响我们代码的性能。
您学会了如何确定代码的效率。
您还学会了如何优化代码,使其更快、更节省内存,更加优雅。
您可以从这本书中获得一个作出明智技术决策的框架。创建优秀软件涉及评估可用选项的权衡,现在您具备了分析每个选项的利弊并为当前任务做出最佳选择的能力。而且,您也有能力思考那些一开始可能并不明显的新选项。
需要注意的是,最好通过基准测试工具测试您的优化。使用这些测试代码实际速度的工具是一个很好的检验,以确保您的优化确实是有帮助的。有许多出色的软件应用程序可以测量您代码的速度和内存消耗。本书中的知识将为您指明方向,而基准测试工具则可以确认您是否做出了正确的选择。
希望您也能从这本书中获得这样的知识:看似复杂和深奥的主题实际上是一系列更简单、更容易理解的概念的组合。不要被那些让概念看起来困难的资源所吓倒,因为它们解释得不好——一个概念总是可以分解成更易于理解的形式。
数据结构和算法的话题宽广而深入,我们只是刚刚触及了表面。通过我们所建立的基础,您现在能够探索和掌握计算机科学的新概念,我希望您继续学习新知识,不断提升技术能力。
祝您好运!
练习
1.你正在开发分析体育运动员的软件。以下是两个不同体育项目的运动员数组:
篮球运动员数组 = [
{first_name: “Jill”, last_name: “Huang”, team: “Gators”},
{first_name: “Janko”, last_name: “Barton”, team: “Sharks”},
{first_name: “Wanda”, last_name: “Vakulskas”, team: “Sharks”},
{first_name: “Jill”, last_name: “Moloney”, team: “Gators”},
{first_name: “Luuk”, last_name: “Watkins”, team: “Gators”}
]
足球运动员数组 = [
{first_name: “Hanzla”, last_name: “Radosti”, team: “32ers”},
{first_name: “Tina”, last_name: “Watkins”, team: “Barleycorns”},
{first_name: “Alex”, last_name: “Patel”, team: “32ers”},
{first_name: “Jill”, last_name: “Huang”, team: “Barleycorns”},
{first_name: “Wanda”, last_name: “Vakulskas”, team: “Barleycorns”}
]
仔细观察可发现,有些球员参与了多个体育项目。Jill Huang 和 Wanda Vakulskas 参与了篮球和足球两项运动。
你需要编写一个函数,接受两个球员数组,并返回在两种运动中都有参与的球员数组。在这个例子中,返回结果应为:[“Jill Huang”, “Wanda Vakulskas”]。
尽管有些球员可能有相同的名字,也可能有相同的姓氏,但我们可以假设只有一个人拥有特定的全名(指名和姓)。
我们可以使用嵌套循环的方式,将一个数组中的每个球员与另一个数组中的每个球员进行比较,但这种方法的时间复杂度为 O(N * M)。你的任务是优化该函数,使其仅在 O(N + M) 的时间内运行。
2.你在编写一个函数,接受一个由连续的整数 0, 1, 2, 3… 直到 N 组成的数组。然而,该数组中缺少一个整数,而你的函数需要返回缺失的那个整数。
例如,以下数组包含了从 0 到 6 的所有整数,但缺少了 4:
[2, 3, 0, 6, 1, 5]
因此,该函数应返回 4。
下一个例子包含了从 0 到 9 的所有整数,但缺少了 1:
[8, 2, 3, 9, 4, 7, 5, 0, 6]
在这种情况下,该函数应返回 1。
使用嵌套循环的方法最坏情况下需要 O(N^2) 的时间复杂度。你的任务是优化代码,使其具有 O(N) 的运行时间。
3.你正在开发股票预测软件。你正在编写的函数接受一个数组,其中包含了特定股票在一段时间内的预测价格。
例如,这个包含了七个价格的数组:
[10, 7, 5, 8, 11, 2, 6]
预测了某只股票在接下来的七天内的价格。(第一天收盘价为 10 美元;第二天收盘价为 7 美元;以此类推。)
你的函数应该计算在单次“买入”交易后紧接着单次“卖出”交易中可能获得的最大利润。在前面的例子中,如果我们在股价为 5 美元时买入,11 美元时卖出,就可以获得最大利润,每股 6 美元。
请注意,如果进行多次买卖,可能可以赚更多的钱,但目前该函数专注于单次购买后单次销售可以获得的最大利润。
现在,我们可以使用嵌套循环来找出每种可能的买卖组合的利润。然而,这种方法的时间复杂度为 O(N^2),对于我们高效的交易平台来说速度太慢了。你的任务是优化代码,使得该函数的时间复杂度仅为 O(N)。
4.你正在编写一个接受数字数组并计算数组中任意两个数字的最大乘积的函数。乍一看,这似乎很简单,因为我们只需要找到两个最大的数字然后将它们相乘。然而,我们的数组中可能包含负数,就像这样:
[5, -10, -6, 9, 4]
在这种情况下,实际上是两个最小的数字 -10 和 -6 的乘积得到了最大的乘积 60。
我们可以使用嵌套循环来计算每对数字的乘积,但这将花费 O(N^2) 的时间。你的任务是优化函数,使其达到高效的 O(N) 时间复杂度。
5.你正在开发一款软件,用于分析来自数百名健康人群的体温数据。这些数据范围从华氏 97.0 度到 99.0 度,这些数据均为健康人士的体温测量值。一个重要的点是:在这个应用中,小数点位数不超过十分之一。
以下是一个体温读数的样本数组:
[98.6, 98.0, 97.1, 99.0, 98.9, 97.8, 98.5, 98.2, 98.0, 97.1]
你需要编写一个函数,将这些读数从低到高进行排序。如果你使用经典的排序算法如快速排序,时间复杂度将为 O(N log N)。然而,在这种情况下,实际上可以编写一个更快的排序算法。
是的,没错。尽管你学过最快的排序算法都是 O(N log N),但这种情况不同。为什么?因为在这种情况下,读数的可能性是有限的。在这种情况下,我们可以用 O(N) 的时间对这些数值进行排序。这可能是 N 乘以一个常数,但仍然被认为是 O(N)。
6.你正在编写一个接受未排序整数数组的函数,并返回其中最长连续序列的长度。这个序列由递增的整数组成,每个整数都比前一个大 1。比如,在数组:
[ [10, 5, 12, 3, 55, 30, 4, 11, 2] ]
最长的连续序列是 2-3-4-5。这四个整数构成一个递增序列,因为每个整数都比前一个大 1。虽然也有 10-11-12 的序列,但只有三个整数。在这种情况下,函数应该返回 4,因为这是从该数组中能形成的最长连续序列的长度。
再举一个例子:
[ [19, 13, 15, 12, 18, 14, 17, 11] ]
这个数组中最长的序列是 11-12-13-14-15,因此函数会返回 5。
如果我们对数组进行排序,然后遍历数组一次就可以找到最长的连续序列。然而,排序本身的时间复杂度为 O(N log N)。你的任务是优化该函数,使其在 O(N) 的时间内完成。
答案
1.我们可以优化这个算法,如果我们问自己:“如果我可以在 O(1) 的时间内神奇地找到所需的信息,我能使我的算法更快吗?”具体来说,当我们遍历一个数组时,我们希望在 O(1) 的时间内“神奇”地查找另一个数组中的那个运动员。为了实现这一点,我们可以首先将其中一个数组转换为哈希表。我们将使用全名(即名字和姓氏)作为键,true(或任何任意项)作为值。
一旦我们将一个数组转换成了这样的哈希表,我们就可以遍历另一个数组。当我们遇到每个运动员时,在哈希表中进行 O(1) 的查找,看看该运动员是否已经参与了另一种运动。如果是,我们就将该运动员添加到我们的多项运动员数组(multisport_athletes
),并在函数结束时返回。
以下是这种方法的代码:
def find_multisport_athletes(array_1, array_2)
hash_table = {}
multisport_athletes = []
array_1.each do |athlete|
hash_table[athlete[:first_name] + " " + athlete[:last_name]] = true
end
array_2.each do |athlete|
if hash_table[athlete[:first_name] + " " + athlete[:last_name]]
multisport_athletes << athlete[:first_name] + " " + athlete[:last_name]
end
end
return multisport_athletes
end
这个算法的时间复杂度为 O(N + M),因为我们只遍历了每组运动员一次。
2.对于这个算法,生成示例以找到模式将非常有帮助。让我们取一个包含六个整数的数组,看看每次移除不同的整数会发生什么:
- [1, 2, 3, 4, 5, 6] : 缺少 0:和 = 21
- [0, 2, 3, 4, 5, 6] : 缺少 1:和 = 20
- [0, 1, 3, 4, 5, 6] : 缺少 2:和 = 19
- [0, 1, 2, 4, 5, 6] : 缺少 3:和 = 18
- [0, 1, 2, 3, 5, 6] : 缺少 4:和 = 17
- [0, 1, 2, 3, 4, 6] : 缺少 5:和 = 16
嗯。当我们移除 0 时,和是 21。当我们移除 1 时,和是 20。当我们移除 2 时,和是 19,依此类推。这绝对看起来像是一个模式!
在我们进一步之前,让我们将这个案例中的 21 称为“完整和”。这是数组仅缺少 0 时的和。
如果我们仔细分析这些情况,我们会发现数组的和总是比“完整和”少了缺失数字的数量。例如,当我们缺少 4 时,和是 17,比 21 少了四。当我们缺少 1 时,和是 20,比 21 少了一个。
因此,我们可以开始算法,通过计算完整和来计算。然后,我们可以从完整和中减去当前和,得到缺失的数字。
以下是此方法的代码:
def find_missing_number(array)
# 计算完整和(仅缺少 0 时的和):
full_sum = 0
(1..array.length).each do |n|
full_sum += n
end
# 计算当前和:
current_sum = 0
array.each do |n|
current_sum += n
end
# 两个和之间的差将是缺失的数字:
return full_sum - current_sum
end
这个算法的时间复杂度是 O(N)。计算完整和需要 N 步,然后计算实际和需要另外 N 步。这是 2N 步,简化为 O(N)。
3.我们可以通过使用贪心算法使这个函数更快。
(也许这并不奇怪,因为我们的代码试图在股票上获得尽可能多的利润。)
为了获得最大利润,我们想要尽可能低的买入价格和尽可能高的卖出价格。我们的贪心算法从将第一个价格分配为买入价格开始。然后,我们遍历所有的价格,一旦找到更低的价格,我们就将其作为新的买入价格。
类似地,当我们遍历价格时,我们检查如果以那个价格出售,我们将获得多少利润。这是通过当前价格减去买入价格来计算的。按照贪心的方式,我们将这个利润保存在一个名为 greatest_profit 的变量中。当我们遍历所有的价格时,每当找到更大的利润时,我们就更新 greatest_profit。
当我们完成遍历价格时,greatest_profit 将保存我们买入和卖出股票一次可以获得的最大利润。
以下是我们算法的代码:
def find_greatest_profit(array)
buy_price = array[0]
greatest_profit = 0
array.each do |price|
potential_profit = price - buy_price
if price < buy_price
buy_price = price
elsif potential_profit > greatest_profit
greatest_profit = potential_profit
end
end
return greatest_profit
end
因为我们只遍历了 N 个价格,所以我们的函数需要 O(N) 的时间。我们不仅赚了很多钱,而且速度也很快。
4.这是另一个算法,通过生成示例找出模式将成为优化的关键。正如练习中所述,最大的乘积可能是负数的结果。让我们看一些数组的各种示例以及它们由两个数字组成的最大乘积:
[-5, -4, -3, 0, 3, 4] -> 最大乘积:20 (-5 * -4)
[-9, -2, -1, 2, 3, 7] -> 最大乘积:21 (3 * 7)
[-7, -4, -3, 0, 4, 6] -> 最大乘积:28 (-7 * -4)
[-6, -5, -1, 2, 3, 9] -> 最大乘积:30 (-6 * -5)
[-9, -4, -3, 0, 6, 7] -> 最大乘积:42 (6 * 7)
观察所有这些情况可能会帮助我们意识到,最大乘积只能由最大的两个数字或最小的两个(负数)数字组成。
有了这个想法,我们应该设计我们的算法来跟踪这四个数字:
- 最大的数字
- 第二大的数字
- 最小的数字
- 第二小的数字
然后,我们可以比较最大两个数字的乘积与最小两个数字的乘积。无论哪个乘积更大,都是数组中的最大乘积。
那么,我们如何找到最大的两个数字和最小的两个数字呢?如果我们对数组进行排序,那会很容易。但这仍然是O(N log N),而题目要求我们可以实现O(N)。
实际上,我们可以在数组的一次遍历中找到所有四个数字。是时候再次贪心了。
以下是代码,后面是它的解释:
def greatest_product(array)
greatest_number = -Float::INFINITY
second_to_greatest_number = -Float::INFINITY
lowest_number = Float::INFINITY
second_to_lowest_number = Float::INFINITY
array.each do |number|
if number >= greatest_number
second_to_greatest_number = greatest_number
greatest_number = number
elsif number > second_to_greatest_number
second_to_greatest_number = number
end
if number <= lowest_number
second_to_lowest_number = lowest_number
lowest_number = number
elsif number > lowest_number && number < second_to_lowest_number
second_to_lowest_number = number
end
end
greatest_product_from_two_highest = greatest_number * second_to_greatest_number
greatest_product_from_two_lowest = lowest_number * second_to_lowest_number
if greatest_product_from_two_highest > greatest_product_from_two_lowest
return greatest_product_from_two_highest
else
return greatest_product_from_two_lowest
end
end
在开始循环之前,我们将 greatest_number 和 second_to_greatest_number 设置为负无穷大。这确保它们的初始值比数组中当前的任何数字都要低。
然后,我们遍历每个数字。如果当前数字大于 greatest_number,我们贪心地将当前数字变成新的 greatest_number。如果我们已经找到了 second_to_greatest_number,我们将 second_to_greatest_number 重新赋值为当前数字之前的 greatest_number。这确保 second_to_greatest_number 确实是第二大的数字。
如果当前迭代的数字小于 greatest_number 但大于 second_to_greatest_number,我们更新 second_to_greatest_number 为当前数字。
我们采用相同的过程来找到 lowest_number 和 second_to_lowest_number。
一旦我们找到了所有四个数字,我们计算最高两个数字的乘积和最低两个数字的乘积,然后返回较大的那个乘积。
5.这个算法优化的关键在于我们正在对有限数量的值进行排序。具体来说,我们在数组中可能找到的温度读数只有21种类型,从97.0、97.1、97.2一直到98.7、98.8、98.9、99.0。
让我们回到练习说明中提到的示例数组:
[98.6, 98.0, 97.1, 99.0, 98.9, 97.8, 98.5, 98.2, 98.0, 97.1]
如果我们把温度数组想象成一个哈希表,我们可以将每个温度作为一个键,出现次数作为值。它可能看起来像这样:
{98.6 => 1, 98.0 => 2, 97.1 => 2, 99.0 => 1, 98.9 => 1, 97.8 => 1, 98.5 => 1, 98.2 => 1}
有了这个想法,我们可以运行一个循环,从97.0到99.0,检查哈希表中该温度的出现次数。每次查找只需要 O(1) 的时间。
然后,我们使用这个出现次数来填充一个新数组。因为我们的循环是从97.0到99.0,所以我们的数组最终会完美地按升序排列。
以下是这个算法的代码:
def sort_temperatures(array)
hash_table = {}
# 填充哈希表中的温度出现次数:
array.each do |temperature|
if hash_table[temperature]
hash_table[temperature] += 1
else
hash_table[temperature] = 1
end
end
sorted_array = []
# 我们一开始将温度乘以10
# 这样在循环中就可以以整数递增温度
# 避免浮点数运算误差:
temperature = 970
# 从 970 循环到 990
while temperature <= 990
# 如果哈希表包含当前温度:
if hash_table[temperature / 10.0]
# 将当前温度的出现次数填充到 sorted_array 中:
hash_table[temperature / 10.0].times do
sorted_array << temperature / 10.0
end
end
temperature += 1
end
return sorted_array
end
你会注意到,在循环中,我们处理了温度乘以了10。这只是为了避免浮点数运算问题,因为在循环中以0.1递增一个变量会导致奇怪的结果,代码无法正确运行。
现在让我们分析一下这个算法的效率。我们需要 N 步来创建哈希表。然后,我们针对所有可能的温度从 97.0 到 99.0 运行了一个 21 次的循环。
在这个循环的每一轮中,我们运行了一个嵌套循环来使用温度填充 sorted_array。然而,这个内部循环永远不会比 N 个温度运行更多次数。这是因为内部循环仅针对原始数组中的每个温度运行一次。
因此,我们需要 2N 步来创建哈希表,21 步来进行外部循环,N 步来进行内部循环。总共是 2N + 21 步,这被简化为一个美丽的 O(N)。
6.这个优化使用了我见过的最精巧的神奇查找技巧。
假设我们正在遍历数字数组,遇到了一个5。让我们问自己这个神奇查找问题:“如果我能以O(1)的时间神奇地找到所需的信息,能让我的算法更快吗?”
要确定5是否是最长连续序列的一部分,我们想知道数组中是否有6。我们也想知道是否有7、8等等。
现在,如果我们首先将数组中的所有数字存储在一个哈希表中,我们实际上可以在O(1)时间内完成每个查找。也就是说,如果我们将数据转移到哈希表中,数组 [10, 5, 12, 3, 55, 30, 4, 11, 2] 可能看起来像这样:
{10 => true, 5 => true, 12 => true, 3 => true, 55 => true, 30 => true, 4 => true, 11 => true, 2 => true}
在这种情况下,如果我们遇到了2,我们可以运行一个循环,持续检查哈希表中是否有下一个数字。如果找到了,我们将当前序列的长度增加1。循环重复这个过程,直到无法找到序列中的下一个数字。每次查找只需要一步。
然而,你可能会问以下问题:这有什么帮助呢?想象一下我们的数组是 [6, 5, 4, 3, 2, 1]。当我们迭代到6时,我们会发现没有从这里开始的序列。当我们到达5时,我们会找到序列5-6。当我们到达4时,我们会找到序列4-5-6。当我们到达3时,我们会找到序列3-4-5-6,以此类推。我们最终仍然会进行大约 N^2 / 2 步来找到所有这些序列。
答案是:只有在当前数字是序列的底部数字时,我们才会开始构建一个序列。所以,当数组中有3时,我们不会构建4-5-6。
但是,我们如何知道当前数字是否是序列的底部数字呢?通过进行神奇查找!
在运行循环以找到一个序列之前,我们将对哈希表进行O(1)查找,检查是否有一个数字比当前数字小1。因此,如果当前数字是4,我们首先检查数组中是否有3。如果有,我们就不会费力去构建一个序列。我们只想从该序列的底部数字开始构建序列;否则就是多余的步骤。
以下是这个算法的代码:
def longest_sequence_length(array)
hash_table = {}
greatest_sequence_length = 0
# 使用数字填充哈希表:
array.each do |number|
hash_table[number] = true
end
# 遍历数组中的每个数字:
array.each do |number|
# 如果当前数字是序列的第一个数字(即没有比它小1的数字):
if !hash_table[number - 1]
# 开始计算当前序列的长度,以当前数字为起点
# 因为这是序列的第一个数字,所以序列的长度当前为1:
current_sequence_length = 1
# 为即将到来的 while 循环建立一个当前数字:
current_number = number
# 当有下一个数字在序列中时运行 while 循环:
while hash_table[current_number + 1]
# 移动到序列中的下一个数字:
current_number += 1
# 将序列的长度增加1:
current_sequence_length += 1
# 贪心地跟踪最长序列长度:
if current_sequence_length > greatest_sequence_length
greatest_sequence_length = current_sequence_length
end
end
end
end
return greatest_sequence_length
end
在这个算法中,我们需要 N 步来构建哈希表。我们需要另外 N 步来遍历数组。我们需要大约另外 N 步来在哈希表中查找数字来构建不同的序列。总而言之,大约是 3N 步,这被简化为 O(N)。