From afb7505be1e7797229e29a96decca5f33c528911 Mon Sep 17 00:00:00 2001 From: Shine wOng <1551885@tongji.edu.cn> Date: Sat, 19 Oct 2019 18:06:57 +0800 Subject: [PATCH] update thu_dsa/chp2/chp2.md, and add exercises.md. --- thu_dsa/chp2/chp2.md | 22 ++++++++++++++++++---- thu_dsa/chp2/exercises.md | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 4 deletions(-) create mode 100644 thu_dsa/chp2/exercises.md diff --git a/thu_dsa/chp2/chp2.md b/thu_dsa/chp2/chp2.md index c4bc97f..6e5cd6b 100644 --- a/thu_dsa/chp2/chp2.md +++ b/thu_dsa/chp2/chp2.md @@ -1,6 +1,18 @@ Conclusion on Chapter Two: Vector ================================= +## 知识脉络 + +本章主要是在讨论`可扩充向量`的实现。首先是讨论了抽象数据类型与具体的数据结构之间的关系,指出这里要实现的向量正是一种抽象数据类型。为了实现可扩充向量,需要首先定义它的抽象接口,针对这些接口的语义可以给出不同的算法实现方法,这里主要需要把握扩容策略,有序向量的唯一化,查找与排序算法,通过这几个算法可以更好的理解抽象数据类型与数据结构之间的区别与联系。 + +为了实现向量的自动扩容,提出了两种算法,即容量递增策略与容量加倍策略,对它们的分析需要使用`分摊分析法`。对于有序向量的唯一化算法,针对无序向量的唯一化,提出了一种`O(n)`的高效算法,需要注意把握它的思想。 + +对于有序向量的查找算法,首先是提出了朴素的二分查找版本,该版本已经可以实现`O(logn)`的时间性能。但是通过进一步的分析可以发现,该算法平衡划分的表象下面隐藏着不平衡的本质,在此基础上提出了`Fibonacci`查找算法,该算法试图通过不平衡的划分对转向成本的不平衡进行补偿,可以证明,经过优化后的平均查找长度从`O(1.50logn)`优化到了`O(1.44logn)`,并且`Fibonacci`查找算法已经是同类比例划分算法中的最优算法。 + +另外一种对朴素二分查找优化的方法是改进分支结构,使得转向成本平衡。可以对该策略进行进一步的改进,使之符合`Vector::search`的`ADT`接口,对该算法的分析需要重点关注其中的不变性。有序向量的查找的另一种策略是插值查找算法,它的基本思想类似于我们查字典的情形,即根据期望的分布情况对目标元素的秩进行估计。 + +从上面的讨论可以看出,有序向量的相关算法相比于无序向量有了很大的性能改进,如何化无序向量为有序向量就成了下面讨论的问题。这里主要是介绍了两种排序算法,即冒泡排序与归并排序,并且对两种算法都进行了不同程度的优化。最后讨论了如何估计这种基于比较的算法(`CBA`式算法)的复杂度下界,即引入比较树。 + ## Vector是一种抽象数据类型 > 辨析抽象数据类型(ADT, Abstract Data Type)与数据结构之间的区别与联系。 @@ -78,7 +90,9 @@ void Vector::expand(){ 如果可以忽略重新分配内存的时间的话,每次扩容的主要时间开销是将数据从原来的区域,拷贝到新分配的区域。显然,对于两种算法而已,这个操作的时间复杂度是相同的,都是$O(n)$。所以,直接比较两个算法每次扩容的时间性能,是没有意义的。 -所以这里需要引入分摊复杂度的概念。所谓分摊复杂度,是指对数据结构连续地实施足够多的次的操作后,所需总体成本分摊到单次操作的时间复杂度。它与平均复杂度是有本质区别的(但是平均复杂度,目前我还没有一个比较深入的理解,只有以后再比较)。下面,我们分别考察两种扩容策略的分摊复杂度。 +所以这里需要引入`分摊复杂度`的概念。所谓分摊复杂度,是指对数据结构连续地实施足够多次的操作后,所需总体成本分摊到单次操作的时间复杂度。它与平均复杂度是有本质区别的。平均复杂度是指假定各种输入情况满足一定的概率分布,进而估算出的加权复杂度均值,这里所做的概率分布可以是不符合真实情况的,也不必考察各种情况出现的次序,因此在一定程度上不符合真实情况,比如说对于扩容策略地分析,我可以假定每次插入都有`50%`的概率扩容,但是后面可以看到,这是不正确的。而分摊复杂度的操作序列确实是可以发生的,它更关注整体的性能,是因此更符合与真实的情况,比平均复杂度更为可信。 + +下面,我们分别考察两种扩容策略的分摊复杂度。 对于递增策略,考虑最坏的情况下,我们连续做了n次插入操作,每次扩容都是递增一个固定的大小K。这样,每次扩容所需要的时间成本为 $$ @@ -195,9 +209,9 @@ int Vector::search(T e, int lo, int hi){ 整个操作非常简单,并且是$O(logn)$的时间复杂度。 -但是邓公说,对于这种经常要用到的算法,还需要考察它复杂度的系数。所以实际上它的时间复杂度是$O(1.50logn)$,这个证明暂时还不会。我们对这个算法进行一个细致的分析: +但是邓公说,对于这种经常要用到的算法,还需要考察它复杂度的系数。所以实际上它的时间复杂度是$O(1.50logn)$,它的证明在教材上可以找到,是需要完全掌握的。我们对这个算法进行一个细致的分析: -可以看到,这个算法虽然是二分查找,可是实际上左右两边的操作时间并不是平衡的,进入右边需要更高的时间代价。这是因为进入左边只需要一次比较,而进入右边需要两次比较,所以右边的时间代价更高。左右两边不平衡会导致这个算法的运行速度变慢,也意味着还有可以优化的办法。 +可以看到,这个算法虽然是`二分`查找,可是实际上左右两边的操作时间并不是平衡的,进入右边需要更高的时间代价。这是因为进入左边只需要一次比较,而进入右边需要两次比较,所以右边的时间代价更高。左右两边不平衡会导致这个算法的运行速度变慢,也意味着还有可以优化的办法。 > 斐波拉契查找 @@ -220,7 +234,7 @@ int Vector::search(T e, int lo, int hi){ } ``` -可以证明(可是目前还不会),当左右划分比例恰好是黄金分割比时,即按照斐波拉契数进行分割,可以达到最优的时间复杂度,为$O(1.44logn)$ +可以证明(在教材上有详细的证明),当左右划分比例恰好是黄金分割比时,即按照斐波拉契数进行分割,可以达到$O(1.44logn)$的时间复杂度。并且也可以证明,在以系数$\lambda$为比例分割的算法中,当$\lambda = \phi = 0.618$时,具有最优的性能,详细可以参见教材。 > 二分查找平衡版本 diff --git a/thu_dsa/chp2/exercises.md b/thu_dsa/chp2/exercises.md new file mode 100644 index 0000000..dcf1b21 --- /dev/null +++ b/thu_dsa/chp2/exercises.md @@ -0,0 +1,33 @@ +教材课后习题回答 +============== + +## Vector.D1 + +> 较之无序向量,有序向量的唯一化可以更快地完成。其中的原因如何理解和解释? + +就找到雷同元素而言,对于无序向量,必须逐个遍历前面的元素,该过程的时间复杂度为`O(k)`,`k`为当前元素的秩;而对于有序向量,若存在雷同元素,则必然处于当前元素的周围,因此找到雷同元素的时间仅为`O(1)`。 + +此外,就删除雷同元素而言,无序向量必须将后面的元素逐个向前移动,时间复杂度是`O(n- k)`,`k`为当前元素的秩;而对于有序向量,可以在遍历的过程中直接定位当前元素最终的位置,从而一步将它移动到最后的位置,时间复杂度仅为`O(1)`。 + +## Vector.D2 + +> 各种查找结果出现的概率不均等时,查找长度应该如何定义和计算 + +平均查找长度应该定义为各个查找结果对应的查找长度的概率期望。计算的时候就按照定义来计算?感觉问题就相当复杂了。 + +## Vector.D3 + +> `fibSearch()`的内层`while`循环,至多能够连续执行几次? + +三次。一般说来是两次,但是考虑到最后一次迭代有`fib(2) = fib(1)`,就是三次了。 + +举个例子,当前`lo = 0, hi = 4`,此时`hi - lo = 4 = fib(5) - 1`,因此`mid = +fib(4) - 1 = 2`。一次比较后,有`get(mid) < val`,转向右侧分支,更新`lo = mid +1 = 3`,此时`hi - lo = 1`。 ++ 第一次内层循环,由于`fib.get() = 2 >= hi - lo`,调用`fib.prev()`,此时`fib.get() = 1`。 ++ 第二次内层循环,由于`fib.get() = 1 >= hi - lo`,调用`fib.prev()`,此时`fib.get() = 1`。 ++ 第三次内层循环,满足`fib.get() = 1 >= hi - lo`,调用`fib.prev()`,此时`fib.get() = 0`。 ++ 第四次不再满足循环条件,退出内层循环。 + +> 改进本节所给的实现,使`Fibonacci`查找严格符合`search()`接口 + +