目录
看算法导论有感,希望能得到大佬指正˙▽˙
附上《算法导论》中的原文:
循环不变式主要用来帮助我们理解算法的正确性。
关于循环不变式,我们必须证明三条性质:
初始化:循环的第一次迭代之前,它为真。
保持:如果循环的某次迭代之前它为真,那么下次迭代之前它仍为真。
终止:在循环终止时,不变式为我们提供一个有用的性质,该性质有助于证明算法是正确的。
当前两条性质成立时,在循环的每次迭代之前循环不变式为真。(当然,为了证明循环不变式在每次迭代之前保持为真,我们完全可以使用不同于循环不变式本身的其他已证实的事实。) 注意,这类似于数学归纳法,其中为了证明某条性质成立,需要证明一个基本情况和一个归纳步。这里,证明第一次迭代之前不变式成立对应于基本情况,证明从一次迭代到下一次迭代不变式成立对应于归纳步。
第三条性质也许是最重要的,因为我们将使用循环不变式来证明正确性。通常,我们和导致 循环终止的条件一起使用循环不变式。终止性不同于我们通常使用数学归纳法的做法,在归纳法中,归纳步是无限地使用的,这里当循环终止时,停止“归纳”。
-----------------《算法导论第三版》第10页
“循环不变式主要用来帮助我们理解算法的正确性。”
首先,算法的目的是为了解决一个问题,所以一个算法正不正确要看这个算法能不能解决问题。
比如:我要写一个能按从小到大的次序去排序一个序列的算法
//待排序的序列A
int A[]={5,2,4,6,1,3};
那么我写出的算法就应该能解决这个问题,也就是如果将 arr 这个序列输入到算法中,那么算法的输出结果应该是这样的:
int A[]={5,2,4,6,1,3};
所以一个算法是否正确的就是看能不能将它所要解决的问题给解决了,如上例,能从小到大依次排好就是正确的算法,反之就是错误的。
算法的正确性是什么意思?
注意:“算法的正确” 和 “算法的正确性” 这两句话的意思并不相同。
"正确"
更强调结果是否正确或合理,指某个事物是否符合事实、逻辑或道德的真实性、正确性、公正性和合理性等标准。例如,我们可能会说一个答案是正确的,因为它经过了仔细的推理、检查和确认,而且能够满足事实、逻辑和公正等方面的要求。
"正确性"
主要指的是一种状态或者特性,指某个事物是否符合规定或预期的标准或要求,是否是真实、可靠和可验证的。在计算机科学中,正确性通常指的是一个程序或算法是否能够按照预期的方式工作,是否符合给定的规范或标准。
因此,"正确" 更偏向于描述某个事物的结果是否合理、正确或符合标准,而 "正确性" 更偏向于描述一种状态或特性。
正确着重于结果,只要这个算法最后的结果符合从小到大依次排序,那么这个算法就是正确的,而对算法是怎么把数据一个个的排好序的这个过程并不关心,排好序就行了,能解决问题就行了,不管你是怎么解决的。就像计算1*2+4,就算先2+4后再*1,结果依然是6,虽然过程是错了,但结果对了就行,这是不严谨的。(所以虽然力扣的自测用例通过了,但后面还有无数的测试用例等着你)
而正确性着重于过程,算法的过程是否都按照预期的标准或要求来进行,你每一步都正确了,那结果自然也是正确的。就像计算 1*2+4,只要你 1*2 算对了,得到2,再把 2+4 也算对了,那自然就能正确的得到 6。每一步都对了,结果自然也就对了,所以不必关心结果,只要过程对了,结果自然而然也就对了。
所以 “算法的正确” 是指这个算法最终能否得到正确的结果,而 “算法的正确性” 则是指这个算法能否每一个步骤都能得到正确的结果。
回到正题, “循环不变式主要用来帮助我们理解算法的正确性。”
算法的正确性关注的是算法的过程是否正确,如果一个算法的过程是正确的,那么算法的结果自然也是正确的,即,如果一个算法的正确性得到保证,那么算法的正确自然也得到保证。所以通过证明算法的正确性,我们能证明一个算法是否正确。
而循环不变式能帮助我们理解算法的正确性,即帮助我们理解这个算法为什么是正确的,而正确性与过程有关,那么循环不变式就与算法的过程有关联。
这块有点绕,原文更是大道至简,所以原文举了个插入排序的例子,下面我们也一样用插入排序这个例子来看看循环不变式是怎么帮助我们理解算法的正确性的。
“关于循环不变式,我们必须证明三条性质: ”
循环不变式是一个正确算法所拥有的性质,并通过分析一个算法有没有这种性质来判断该算法是否是正确的。“循环” 说明这个算法与循环有关(废话),所以, “循环不变式主要用来帮助我们理解算法的正确性。” 这句话更准确的说法应该是 “循环不变式主要用来帮助我们理解以循环为结构的算法的正确性”。
“不变” 说明这个算法在循环过程中,有一些性质或条件是不变的。
“式”,一个表达式或是一个约束条件。
“关于循环不变式,我们必须证明三条性质:” 这句话的意思是,要证明一个算法是否满足循环不变式,那么这个算法应满足三条性质:
① 初始化:循环的第一次迭代之前,它为真。
② 保持:如果循环的某次迭代之前它为真,那么下次迭代之前它仍为真。
③ 终止:在循环终止时,不变式为我们提供一个有用的性质,该性质有助于证明算法是正确的。
那么进入正题,现在用插入排序这个例子来理解上面说到的这些东西。
插入排序的伪代码描述:
原文用了个伪代码来描述插入排序。
注意:原文这里的数组下标是从1开始的,所以 j 的初值为2,并且 while 循环这里貌似也有错漏,i >0 应改成 i>=0。
测试如下:
i>0时,arr没有从小到大进行排序。
i>=0时,结果正确了
为了正确和方便,我将其转换成c语言的形式来描述
插入排序的C语言代码如下:
void INSERTION_SORT(int A[],int A_length) {
for (int j = 1; j < A_length; j++) {
int key = A[j];
//将A[j]插入排序后的序列A[1..j–1]。
int i = j - 1;
//以从小到大的次序进行排序
while (i >= 0 && A[i] > key) {
A[i + 1] = A[i];
i = i - 1;
}
A[i + 1] = key;
}
}
调用逻辑:
int main() {
int A[]={5,2,4,6,1,3};
int A_length = sizeof(A) / sizeof(A[0]);
INSERTION_SORT(A, A_length);
}
插入排序的基本思想:
将待排序的元素插入到已排序的序列中的合适位置,逐步构建有序序列。
“式”
照着基本思想,我们先来得到 “循环不变式” 中的 “式”。
“将待排序的元素插入到已排序的序列”,那插入元素前的这个序列得是个有序的,插入后,“逐步构建有序序列”,那么可能还有下一步。
而下一步又要“将待排序的元素插入到已排序的序列”,那么插入元素前这个序列得是个有序的,所以上一步插入的元素要插在“序列中的合适位置”以保证序列依然有序。
整了半天,我们就知道,插入排序算法要想构建出一个有序序列,那么得保证插入元素前这个序列得是个有序的,以让这一步的元素能插入到合适位置,从而保持有序状态,进而保证下一步插入元素前,序列是有序的。
将上面的话精简为:“插入前有序”。这精简后的话就是 “循环不变式” 中的 “式”,而三种性质中提到的它为真、它仍为真。它是谁啊?这个它指的就是“式”,它为真即是在说“插入前有序”这个条件为真。
“循环不变”
再次回顾一下三个性质
① 初始化:循环的第一次迭代之前,它为真。
② 保持:如果循环的某次迭代之前它为真,那么下次迭代之前它仍为真。
③ 终止:在循环终止时,不变式为我们提供一个有用的性质,该性质有助于证明算法是正确的。
而“循环不变式”中的 “不变” 要与 “循环” 合在一起看,即在“循环”过程中,“式”保持“不变”,也就是说从开始到结束,“插入前有序”这个条件始终满足。原文中各处提到的“循环不变式”,其实指的就是这个“式”。而最后一个性质终止说“提供一个有用的性质”,这个有用的性质指的是循环不变式。(所以实际上只有前两条算是性质,最后一条可当成是由前两条成立后得出的结论,我怀疑是为了与数学归纳法做区分而加进来凑数的)
上述话的划线部分被拆分成三个部分,这三个部分就是“循环不变式”所拥有的三个性质,初始化和终止对应“开始”和“结束”。保持对应“始终满足”,是 “开始” 过渡到 “结束” 的中间部分。三个性质合起来就是一个算法的过程。
回到问题起点,“循环不变式主要用来帮助我们理解算法的正确性。”
在上面我们讨论了循环不变式与算法的过程有关联,在这里就要说明为什么有关联,因为循环不变式身上的这三个性质就是处于算法的执行过程中啊,并且提出了“式”的概念,如果“式”从算法的初始化到终止这个过程仍为真,那么这个算法就是正确的,即这个算法是满足循环不变式的。
所以,所谓的循环不变式,指的是如果有一个 “式” 在“循环”中是“不变”的,那么称这个算法满足循环不变式,即该算法是正确的。
补充说明:
循环不变式是一个正确算法所拥有的性质,通过分析一个算法有没有这种性质来判断该算法是否是正确的,并且循环不变式同时还是一个方法,至于到底是方法还是性质则要看它处于的语境,比如:
“关于循环不变式,我们必须证明三条性质:”
这里的循环不变式指的是性质,证明有没有循环不变式这性质得先证出它的三条性质。
“在本章后面以及其他章中,我们将采用这种循环不变式的方法来证明算法的正确性。 ”
指名道姓的,这里的循环不变式指的是方法。
所以 “循环不变式主要用来帮助我们理解算法的正确性。”,其本质就是在说 “正确算法所拥有的三种性质,能用来帮助我们理解算法的正确性。”,理解算法的正确性,就是理解算法的过程是否是正确的。
接下来,真正的进入正题。通过插入排序来举例,这三种性质是如何证明算法是否是正确的。
开始之前,先探讨下原文中说到的数学归纳法,这对我们理解三种性质有所帮助。
循环不变式与数学归纳法
首先梦回高中,熟悉下数学归纳法是什么
数学归纳法是一种用于证明数学命题的方法,其基本思想是通过证明命题在某些特定情况下成立,然后通过假设命题在前一项成立的情况下,在下一项也成立,最终证明该命题在所有情况下都成立。
请看题目:
假设有一个数列 {a₁, a₂, a₃, ... , aₙ, ...},满足以下两个条件:
1. a₁ = 1;
2. 对于任意正整数n,有aₙ+1 = aₙ + 2n + 1。
我们想要证明:对于任意正整数n,有aₙ = (n+1) ²。
证明:
① 当n=1时,根据条件1,a₁=1;而根据条件2,a₂ = a₁ + 2x1 + 1 = 4,因此 a₁ = (1+1) ² = 4,成立。
② 假设当n=x时命题成立,即 aₓ = (x+1)²。则有:
aₓ+1 = aₓ + 2x + 1 = (x+1) ² + 2x + 1 = x ² + 2x + 1 + 2x + 2 = (x+2) ²
因此,当n=x+1时,命题也成立。
③ 根据数学归纳法原理,命题对于所有正整数n成立。
综上所述,对于任意正整数n,有aₙ = (n+1) ²。
数学归纳法的步骤:
① (基本情况) 证明当 n 取第一个值 a₁ 时命题成立;
② (归纳步) 假设n=x时命题成立,即可证明当n=x+1时命题也成立.
只要完成这两个步骤,就可以断定命题对从 n₁ 开始的所有正整数 n 都成立。其中①是②的基础,只有①成立了才能继续推导②的成立。
循环不变式的三个性质:
① 初始化:循环的第一次迭代之前,它为真。
② 保持:如果循环的某次迭代之前它为真,那么下次迭代之前它仍为真。
③ 终止:在循环终止时,不变式为我们提供一个有用的性质,该性质有助于证明算法是正确的。
仔细对比两者可发现,除了没有第三项外,数学归纳法与循环不变式的前两项所表达的意思几乎一模一样,并且都是只有①成立了才能继续推导②的成立。不同的是,数学归纳法的归纳步的n=x,这个x是任意大小的,这一归纳步,能踏出一两步的距离,亦能踏出无限远的距离。而循环不变式是证明一个以循环为结构的算法的正确性,循环是有终止条件的,所以两者的不同之处就在此处:一个可以无限往前伸展,一个则有终止的时刻。
以上面数学归纳法的例题作为模版,魔改后的题目:
假设有一个int型数组A= {a₁, a₂, a₃, ... , aₙ},请写一个算法使其满足以下需求:
对于这个问题,我们写了个插入排序来解决:
//假设题目提供了c语言的接口,就长这样
void INSERTION_SORT(int A[],int A_length) {
for (int j = 1; j < A_length; j++) {
int key = A[j];
//将A[j]插入排序后的序列A[1..j–1]。
int i = j - 1;
//以从小到大的次序进行排序
while (i >= 0 && A[i] > key) {
A[i + 1] = A[i];
i = i - 1;
}
A[i + 1] = key;
}
}
用循环不变式来证明插入排序的正确性
写完后就想着看看写得对不对,闲得蛋疼的,我们就用了循环不变式的方法来检查,首先弄个自测用例:
int A[]={5,2,4,6,1,3};
要判断这个算法是否拥有循环不变式的性质,先证明第一条性质是否成立
① 初始化:循环的第一次迭代之前,它为真。
这个它我们已经知道指的是“插入前有序”,那么现在来看看是否满足第一条性质。
循环和迭代
循环:指的是在满足条件的情况下,重复执行同一段代码。比如,while语句。
迭代:指的是按照某种顺序逐个访问列表中的每一项。比如,for语句。
迭代的本质:利用计算机运算速度快、适合做重复性操作的特点,让计算机对一组指令(或一定步骤)进行重复执行,在每次执行这组指令(或这些步骤)时,都从变量的原值推出它的一个新值。
循环和迭代的异同
循环和迭代的共同点在于,它们都是在描述一个多次操作。
不同点在于,循环侧重于描述每次操作和上一次操作【相同之处】,而迭代侧重于描述每次操作和上一次操作的【不同之处】。
-----------------------循环,迭代和递归
循环的第一次迭代之前,这个循环不是指的内层循环while或者外层循环for,而是指该整个算法的循环,因为单独只看for或者while构不成一个完整的算法,逻辑结构有缺失,所以这里看的是整个算法的循环的第一次迭代之前。
那么之前是哪,处于什么时刻?来看原文的描述:
对应到我们这里,就是刚执行了①:给 j 赋了初值1,但还没执行②:判断是否满足循环条件
那么在这个时刻,“插入前有序”成立吗?我们来看看这个时刻的状态
int A[]={5,2,4,6,1,3};//待排序数组
for (int j = 1; j < A_length; j++) {//只赋了初值,未进行判断
int key = A[j];
int i = j - 1;
while (i >= 0 && A[i] > key) {
A[i + 1] = A[i];
i = i - 1;
}
A[i + 1] = key;
}
循环的第一次迭代之前,再次注意:这里的循环是指整个算法的循环,而不是单单 for 循环那一行。我们以整体的眼光来看,此时 j==1,A_length为 9,可以进入for循环,key==A[1]==2,第一次迭代要干的事是:将 A[1] 这个元素插入到 A[1] 前面的序列中的合适位置,由while循环找位置,找到后退出while循环,再进行插入。那么 A[1] 前面的这个序列是有序的吗?A[1] 前面只有一个数 A[0],当然是有序的,所以满足“插入前有序”。
因此,第一条性质成立。
以第一条性质为基础,接着来证第二条。
② 保持:如果循环的某次迭代之前它为真,那么下次迭代之前它仍为真。
原文的证法:
这里所提到的形式主义
形式主义是强调形式而忽视内容的一种思维或做事方式。
比如 “Peano 公理化系统” 通过定义符号系统和证明规则,可以在不涉及实际内容的情况下推导出数学命题。这是形式主义的基本思想之一。
在某些情况下,形式主义可能会导致思维或行动的僵化,甚至会使人们失去创造性和创新性。因此,需要在适当的情况下采用形式化方法和规则,但也要注意在实际问题中结合具体情况,灵活运用和调整方法。
原文是以非形式化的分析来证明第二条性质的,即结合具体问题去分析问题,因为证明这性质如果没有具体的例子去帮助分析的话,会让人感到无从下手,原文也提到了 “我们不愿陷入形式主义的困境”,所以为了避免麻烦,因此我们也采用非形式化的分析来证明该性质,在这里,我们的具体问题是将 A 这个数组按照从小到大的次序进行排序。
好的回归正题,为了整篇文章不太过于臃肿,这里只分析前几个迭代过程:
int A[]={5,2,4,6,1,3};//待排序数组
for (int j = 1; j < A_length; j++) {
int key = A[j];
int i = j - 1;
while (i >= 0 && A[i] > key) {
A[i + 1] = A[i];
i = i - 1;
}
A[i + 1] = key;
}
第一次迭代:
j==1,i==0;将A[1]插入到序列 [5] 中,“插入前有序”满足吗?满足,通过while循环找到合适位置后插入,此时序列为 [2,5]
第二次迭代:
j==2,i==1;将A[2]插入到序列 [2,5] 中,“插入前有序”满足吗?满足,通过while循环找到合适位置后插入,此时序列为 [2,4,5]
此时可得出,循环的第二次迭代之前“插入前有序”为真,那么第三次迭代之前“插入前有序”仍为真。循环结构是固定的,“过去”的都计算正确了,然后计算结果传递到“现在”,“现在”经过固定的执行步骤后又计算得到正确的结果,并传递给未来,因此可证得:
如果循环的某次迭代之前它为真,那么下次迭代之前它仍为真。
最后的这个第三条性质终止,其实就是结论了,由于第二条性质,我们可得知:如果循环的最后一次迭代之前它为真,那么下次迭代之前它仍为真,这里的下次迭代之前即是最后一次迭代之后,最后一次迭代后已退出了循环,因此最后结束循环时它仍为真。
前两条性质都成立,因此我们得出结论:
③ 终止:在循环终止时,始终保持的“插入前有序”为我们提供一个有用的性质,即循环不变式,说明该算法满足循环不变式,所以该算法是正确的。