算法图解阅读笔记
# 比较数组和链表
链表存在一个问题。在需要读取链表的最后一个元素时,你不能直接读取,因为你不知道 它所处的地址,必须先访问元素#1,从中获取元素#2 的地址,再访问元素#2 并从中获取元素#3 的地址,以此类推,直到访问最后一个元素。需要同时读取所有元素时,链表的效率很高:你读 取第一个元素,根据其中的地址再读取第二个元素,以此类推。但如果你需要跳跃,链表的效率真的很低
数组与此不同:你知道其中每个元素的地址。例如,假设有一个数组,它包含五个元素,起 始地址为 00,那么元素05 的地址是多少呢?
只需执行简单的数学运算就知道:04。需要随机地读取元素时,数组的效率很高,因为可迅速找到数组的任何元素。在链表中,元素并非靠在一起的,你无法迅速计算出第五个元素的内存地址,而必须先访问第一个元素以获取第二个元素的地址,再访问第二个元素以获取第三个元素 的地址,以此类推,直到访问第五个元素。
下面列出了常见的数组和链表操作的运行时间。
使用场景: 数组用得很多,因为它支持随机访问。有两种访问方式:随机访问和顺序访问。顺序访问意味着从第一个元素开始逐个地读取元素。链表只能顺序访问:要读取链表的第十个元素,得先读取前九个元素,并沿链接找到第十个元素。随机 10 访问意味着可直接跳到第十个元素。
# 对递归的理解
Leigh Caldwell 在 Stack Overflow 上说的一句话:“如果使用循环,程序的性能可能更高;如果使用递归,程序可能更容易理解。如何选择要看什么对你来说更重要。”
# 平均情况和最糟情况
快速排序的性能高度依赖于你选择的基准值。假设你总是将第一个元素用作基准值,且要处理的数组是有序的。由于快速排序算法不检查输入数组是否有序,因此它依然尝试对其进行排序。
注意,数组并没有被分成两半,相反,其中一个子数组始终为空,这导致调用栈非常长。现 在假设你总是将中间的元素用作基准值,在这种情况下,调用栈如下。
调用栈短得多!因为你每次都将数组分成两半,所以不需要那么多递归调用。你很快就到达 了基线条件,因此调用栈短得多。
第一个示例展示的是最糟情况,而第二个示例展示的是最佳情况。
在最糟情况下,栈长为 O(n),而在最佳情况下,栈长为 O(log n)。
现在来看看栈的第一层。你将一个元素用作基准值,并将其他的元素划分到两个子数组中。 这涉及数组中的全部 8 个元素,因此该操作的时间为 O(n)。在调用栈的第一层,涉及全部 8 个元素, 但实际上,在调用栈的每层都涉及 O(n)个元素。
即便以不同的方式划分数组,每次也将涉及 O(n)个元素。
因此,完成每层所需的时间都为 O(n)。
在这个示例中,层数为 O(log n)(用技术术语说,调用栈的高度为 O(log n)),而每层需要的 时间为 O(n)。因此整个算法需要的时间为 O(n) _ O(log n) = O(n log n)。这就是最佳情况。
在最糟情况下,有 O(n)层,因此该算法的运行时间为 O(n) _ O(n) = O(n2)。
知道吗?这里要告诉你的是,最佳情况也是平均情况。只要你每次都随机地选择一个数组元 素作为基准值,快速排序的平均运行时间就将为 O(n log n)。
快速排序是最快的排序算法之一,也 是 分而治之(divide and conquer,D&C) 典范。
# 散列冲突
大多数语言都提供了散列表实现,你不用知道如何实现它们。有鉴于此,我就不 再过多地讨论散列表的内部原理,但你依然需要考虑性能!要明白散列表的性能,你得先搞清楚 什么是冲突。
你可能已经看出了问题。如果你要将苹果的价格存储到散列表中,分配给你的是第一个位置。
接下来,你要将香蕉的价格存储到散列表中,分配给你的是第二个位置。
不好,这个位置已经存储了苹果的价格! 怎么办? 这种情况被称为冲突(collision):给两个键分配的位置相同。这是个问题。如果你将鳄梨的价格存储到这个位置,将覆盖苹果的价格, 以后再查询苹果的价格时,得到的将是鳄梨的价格!冲突很糟糕,必须要避免。处理冲突的方式 很多,最简单的办法如下:如果两个键映射到了同一个位置,就在这个位置存储一个链表。
在这个例子中,apple 和 avocado 映射到了同一个位置,因此在这个位置存储一个链表。在需 要查询香蕉的价格时,速度依然很快。但在需要查询苹果的价格时,速度要慢些:你必须在相应 的链表中找到 apple。如果这个链表很短,也没什么大不了——只需搜索三四个元素。但是,假设你工作的杂货店只销售名称以字母 A 打头的商品。
等等!除第一个位置外,整个散列表都是空的,而第一个位置包含一个很长的列表!换言之, 这个散列表中的所有元素都在这个链表中,这与一开始就将所有元素存储到一个链表中一样糟 糕:散列表的速度会很慢。
这里的经验教训有两个。
- 散列函数很重要。前面的散列函数将所有的键都映射到一个位置,而最理想的情况是, 散列函数将键均匀地映射到散列表的不同位置。
- 如果散列表存储的链表很长,散列表的速度将急剧下降。然而,如果使用的散列函数很好,这些链表就不会很长!
在最糟情况下,散列表所有操作的运行时间都为 O(n)——线性时间,这真的很慢。我们来将 散列表同数组和链表比较一下。
在平均情况下,散列表的查找(获取给定索引处的值)速度与数组一样快,而插入和删除速度与链表一样快,因此它兼具两者的优点! 但在最糟情况下,散列表的各种操作的速度都很慢。 因此,在使用散列表时,避开最糟情况至关重要。为此,需要避免冲突。
# 填装因子
散列表的填装因子很容易计算。
填装因子越低,发生冲突的可能性越小,散列表的性能越高。一个不错的经验规则是:一旦填装因子大于 0.7,就调整散列表的长度。
你可能在想,调整散列表长度的工作需要很长时间!你说得没错,调整长度的开销很大,因 此你不会希望频繁地这样做。但平均而言,即便考虑到调整长度所需的时间,散列表操作所需的 时间也为 O(1)。
# NP完全问题
NP完全问题的简单定义是,以难解著称的问题,如旅行商问题和集合覆盖问题。很多非常 聪明的人都认为,根本不可能编写出可快速解决这些问题的算法。
NP完全问题无处不在!如果能够判断出要解决的问题属于NP完全问题就好了,这样就不用 去寻找完美的解决方案,而是使用近似算法即可。但要判断问题是不是NP完全问题很难,易于 解决的问题和NP完全问题的差别通常很小。
例如,如何找 出从A点到B点的最短路径。
但如果要找出经由指定几个点的的最短路径,就是旅行商问题——NP完全问题。简言之, 没办法判断问题是不是NP完全问题,但还是有一些蛛丝马迹可循的。
- 元素较少时算法的运行速度非常快,但随着元素数量的增加,速度会变得非常慢。
- 涉及“所有组合”的问题通常是NP完全问题。
- 不能将问题分成小问题,必须考虑各种可能的情况。这可能是NP完全问题。
- 如果问题涉及序列(如旅行商问题中的城市序列)且难以解决,它可能就是NP完全问题。 - 如果问题涉及集合(如广播台集合)且难以解决,它可能就是NP完全问题。
- 如果问题可转换为集合覆盖问题或旅行商问题,那它肯定是NP完全问题。
# 动态规划局限
功能强大,它能够解决子问题并使用这些答案来解决大问题。但仅当 每个子问题都是离散的,即不依赖于其他子问题时,动态规划才管用。
整本书比较比较浅显, 算是算法入门导论风格的科普书