我自学算法,略有所得,发现一些算法优化的过程其实就是消除重复的过程。
例如从时间复杂度Θ(n2)的冒泡排序、插入排序到时间复杂度Θ(nlog2n)的快速排序、归并排序。
冒泡排序算法的运作如下: 1、比较相邻的元素。如果第一个比第二个大,就交换他们两个。 2、对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对。这步做完后,最后的元素会是最大的数。 3、针对所有的元素重复以上的步骤,除了最后一个。 4、持续每次对越来越少的元素重复上面的步骤,直到没有任何一对数字需要比较。 |
例如对于长度为5的数组[6, 0, 5, 10, 3],冒泡排序每步的比较对象和运行结果如下: [0, 6, 5, 10, 3]; [0, 5, 6, 10, 3]; [0, 5, 6, 10, 3]; [0, 5, 6, 3, 10]; [0, 5, 6, 3, 10]; [0, 5, 6, 3, 10]; [0, 5, 3, 6, 10]; [0, 5, 3, 6, 10]; [0, 3, 5, 6, 10]; [0, 3, 5, 6, 10] |
容易看到,原始的冒泡排序其实做了很多重复的比较。这是因为之前的比较运算得到的信息并没有得到利用,上面被框住的两个地方就明显地体现了这一点,第一次循环得到的5<6的信息在比较之后就没用了,所以第二次循环时又再比较了一次。
插入排序算法的运作如下: 1、从第一个元素开始,该元素可以认为已经被排序 2、取出下一个元素,在已经排序的元素序列中从后向前扫描 3、如果该元素(已排序)大于新元素,将该元素移到下一位置 4、重复步骤3,直到找到已排序的元素小于或者等于新元素的位置 5、将新元素插入到该位置后 6、重复步骤2~5 |
例如对于长度为5的数组[6, 0, 5, 10, 3],插入排序每步的比较对象和运行结果如下: [0, 6, 5, 10, 3]; [0, 5, 6, 10, 3]; [0, 5, 6, 10, 3]; [0, 5, 6, 10, 3]; [0, 5, 6, 3, 10]; [0, 5, 3, 6, 10]; [0, 3, 5, 6, 10]; [0, 3, 5, 6, 10] |
原始的插入排序虽然没有明显地看到重复,但是仍然没有充分利用好前面步骤得到的排序信息。
原始的冒泡排序和插入排序都是没有很好地利用之前步骤中比较得到的排序信息,导致很多元素之间需要两两比较和两两交换,从而导致了时间复杂度为Θ(Cn2)= Θ(n(n-1)/2) = Θ(n2)。换句话说,就是没有利用好:如果之前得到a < b,b < c,那么不用比较a和c就可以知道a < c。
而快速排序和归并排序则能比较好地利用之前步骤中比较得到的排序信息,避免了不必要的两两比较和两两交换。
快速排序算法的运作如下: 1、从序列中挑出一个元素,称为 "基准"(pivot), 2、重新排序序列,所有元素比基准值小的摆放在基准前面,所有元素比基准值大的摆在基准的后面(相同的数可以到任一边)。在这个分区退出之后,该基准就处于数列的中间位置。这个称为分区(partition)操作。 3、递归地(recursive)把小于基准值元素的子序列和大于基准值元素的子序列排序。 |
例如对于长度为5的数组[6, 0, 5, 10, 3],快速排序每步的比较对象和运行结果如下: [0, 6, 5, 10, 3]; [0, 5, 6, 10, 3]; [0, 5, 6, 10, 3]; [0, 5, 3, 6, 10]; [0, 5, 3, 6, 10]; [0, 5, 3, 6, 10]; [0, 3, 5, 6, 10] |
虽然在元素数少的情况下不能明显看出快速排序的优胜之处,但是我们还是可以通过分析看出,通过第一步6与其他元素比较和交换之后,比6大和比6小的两组元素之间就不需要再做两两比较和两两交换了。我们可以从这里看出,快速排序能比较充分地利用每次比较得到的信息来避免重复的比较和交换。
但是快速排序中仍然可能出现所有元素都做两两比较和两两交换的情况,所以快速排序的最坏时间复杂度也是O(n2)。
归并排序算法的运作如下: 1、把序列分为两个子序列,把每个子序列再分为两个子序列,如此循环直到每个子序列都剩下一个元素,这时这些最小的子序列都可看作是已排好序的 2、申请空间用来存放归并后的序列 3、设定两个指针,最初位置分别为两个已经排序的相邻子序列的起始位置。比较两个指针所指向的元素,选择相对小的元素放入到合并空间,并移动指针到下一位置 4、重复步骤3直到某一指针到达序列尾,将另一序列剩下的所有元素直接复制到合并序列尾 5、对归并好的序列重复步骤2、3、4,直到原序列的所有元素都得到归并排序 |
例如对于长度为5的数组[6, 0, 5, 10, 3],归并排序每步的比较对象和运行结果如下: [0, 6, ?, ?, ?]; [?, ?, 5, 10, ?]; [0, 6, 5, 10, ?]; [0, 5, 6, 10, ?]; [0, 5, 6, 10, ?]; [0, 5, 6, 10, 3]; [0, 3, 5, 6, 10] |
归并排序也尽量避免了多余的元素比较,例如子序列[0, 6]和[5, 10]归并时,比较得到0 < 5后,归并排序不会再比较0和10;子序列[0, 5, 6, 10]和[3]归并时,比较得到3 < 5后就不再比较3和6、10。
KMP串匹配算法也体现了消除重复。我们可以从一个实例看出这点:从字符串“ababababc”(母串)中匹配“ababc”(子串)。简单的串匹配算法的比较方法如下
a b a b a b a b c a b a b c |
我们可以发现,中间有两次匹配其实是不必要的。就拿第一次到“c”不匹配的时候分析,可以看到这时候应该把要子串“ababc”的开头对齐母串的第三个元素,而要继续比较的是刚刚比较不匹配的母串的第五个元素“a”与子串的第三个元素“a”:
a b a b a b a b c a b a b c |
其实母串中被比较过的元素是不用再拿来比较的。而出现不匹配时,将子串往前移动多少跟子串自身的重复性是有关的。如果子串是像“abcde”这样的没有重复开头的形式的,那么出现不匹配时一定最好把子串移动到当前母串还没被比较的元素的位置重新开始匹配,例如:
a b c d f a b c d e a b c d e |
因为可以肯定除了子串的开头,跟子串其他元素匹配的都不能跟子串的开头匹配。
如果子串是像上面例子中的“ababc”这样的有重复开头的形式的,这个子串中间跟开头一样都是“ab”,那么在子串的“c”出现不匹配时,我们知道母串的第三、四个元素已经又跟子串开头的“ab”匹配好了,所以我们只需要继续比较母串的第五个元素和子串的第三个元素。
用数学符号来表达就是,如果子串x[]的元素a ~ a+ n跟开头的元素0 ~ n一样,即
x[a]= x[0], x[a+1]=x[1], x[a+2]=x[2], …, x[a+n]=x[n],而且在a ~ a+ n这个范围内的元素在跟母串y[]中的b + a~ b + a + n这个范围内的元素比较的时候出现不匹配,假设比较到x[a+k]和y[b+a+k](k = 0, 1, 2, …, n)的时候出现不匹配,那么我们知道y[b+a], y[b+a+1],…, y[b+a+k-1]又跟子串开头的x[0], x[1], x[2], …, x[k-1]匹配好了,所以我们只需要继续匹配x[k]和y[b+a+k]、x[k+1]和y[b+a+k+1]……即相当于把子串x向前移动a个元素继续匹配。
在了解子串本身的重复性在KMP算法中的作用后,我们就能理解KMP为子串的每个元素建立一个索引值的做法了。