使用移位代替交换操作的几个典型案例分析
(巧若拙出品,欢迎转载,请注明出处)
交换变量值是编程中的基本操作,它通常由3个赋值语句组成。正是因为每次交换操作都需要执行3条赋值语句,效率不算高,所以在有些情况下我们使用移位来代替交换操作,以提高工作效率。
例1.插入排序。我们都知道经典的插入排序是采用向后移动元素的方式腾出插入位置,以便将被插入的元素a(i)放到正确的位置上去。事实上也可以用类似冒泡排序的交换方法把a(i)依次向前交换到正确的位置,代码如下所示:
For i = 2 To n
j = i - 1
Do While a(j + 1) < a(j)
tmp = a(j + 1): a(j + 1) = a(j): a(j) = tmp
j = j - 1
If j = 0 Then Exit Do
Loop
Next i
在上面的例子中,虽然我们使用的是类似冒泡排序的交换方法,但由于它扫描的是已排序区域,而且只要找到第一个不小于a(i)的元素就停止交换,结束内层循环,所以它不符合冒泡排序的特征,只能看成是插入排序的一个变例。
我们可以用移位的方法来代替上例中的交换操作,实现经典的插入排序算法,代码如下所示:
For i = 2 To n
tmp = a(i): j = i - 1
Do While tmp < a(j)
a(j + 1) = a(j): j = j - 1
If j = 0 Then Exit Do
Loop
a(j + 1) = tmp
Next i
例2.构造大根堆。堆是一棵顺序存储的完全二叉树,它除了最后一层之外的其他每一层都被完全填充,并且所有结点都保持向左对齐。大根堆是一种特殊的堆,它的特征是每个结点的值都不小于其孩子结点的值。与之对应的是小根堆,其特征是每个结点的值都不大于其孩子结点的值。如下图所示:
我们发现用数组来表示堆是非常不错的选择,它不仅没有浪费空间,还可以很方便的表示双亲结点和孩子结点的关系:若某个结点的下标为p,则其双亲结点的下标f=p\2,其左孩子结点的下标lc=p*2,其右孩子结点的下标rc=p*2+1。
我们可以使用类似插入排序的算法把一个无序数组改造成大根堆(或者小根堆),其中的一个基本操作就是将位于某个大根堆尾部的待插入元素上移到正确位置。
例如,我们可以在上图中的大根堆中再插入一个值为8的新元素,如下图所示:
因为大根堆的特征是每个结点的值都不小于其孩子结点的值,所以位于大根堆尾部的新元素必须向上移动到正确位置,才能保证整个堆继续保持大根堆的特征。
具体操作方法如下:
第1步:因为a(10)大于它的双亲结点a(5),故需要交换a(10)和a(5)的值;
第2步:因为a(5)大于它的双亲结点a(2),故需要交换a(5)和a(2)的值;
第3步:因为a(2)不大于它的双亲结点a(1),故无需做交换操作。此时新元素已经放到了正确的位置,新的大根堆构造完成,上移行动结束。
构造大根堆的完整代码如下:
Private Sub Command1_Click()
Dim a(1 To 100) As Integer, tmp As Integer
Dim n As Integer, i As Integer, p As Integer, f As Integer
n = 10
a(1) = 2: a(2) = 5: a(3) = 4: a(4) = 7: a(5) = 3: a(6) = 8: a(7) = 9: a(8) = 6: a(9) = 1: a(10) = 8
For i = 2 To n '从a(2)开始,逐个插入新元素构造大根堆
p = i: f = p \ 2
Do While a(p) > a(f) '若孩子结点大于双亲结点,则交换其值
tmp = a(p): a(p) = a(f): a(f) = tmp
p = f '指向双亲结点
f = p \ 2 '更新双亲结点下标
If f = 0 Then Exit Do
Loop
Next i
List1.Clear
For i = 1 To n
List1.AddItem a(i)
Next i
End Sub
我们可以观察到构造大根堆的算法和插入排序算法非常相似,既然插入排序可以用移位代替交换操作,那么构造大根堆也可以,相关代码如下:
Private Sub Command2_Click()
Dim a(1 To 100) As Integer, tmp As Integer
Dim n As Integer, i As Integer, p As Integer, f As Integer
n = 10
a(1) = 2: a(2) = 5: a(3) = 4: a(4) = 7: a(5) = 3: a(6) = 8: a(7) = 9: a(8) = 6: a(9) = 1: a(10) = 8
For i = 2 To n '从a(2)开始,逐个插入新元素构造大根堆
tmp = a(i): p = i: f = p \ 2
Do While tmp > a(f) '若孩子结点大于双亲结点,则交换其值
a(p) = a(f)
p = f '指向双亲结点
f = p \ 2 '更新双亲结点下标
If f = 0 Then Exit Do
Loop
a(p) = tmp '将新元素插入到正确位置
Next i
List1.Clear
For i = 1 To n
List1.AddItem a(i)
Next i
End Sub
例3.快速排序算法。在各种基于关键码比较的内排序算法中,快速排序是实践中平均速度最快的算法之一。快速排序算法最基本的思想是划分,即按照某种标准将待排序列分割成独立的两个子序列(分别称为“小记录”和“大记录”),然后分别对这两个子序列分别快速排序,以达到整个序列有序。
根据划分算法的不同,快速排序有许多不同的实现方法,本例是其中较为典型的一种。为了表达的方便,我们简单的选取a[left]作为枢纽元,你可以在改进算法中选择更适合的枢纽元,以提高算法效率。
本例采用双向扫描划分数组,通过从数组的两端交替向中间扫描,每扫描一次就把“不合群”的元素交换到另一侧的方法,确保每个位置的元素最多只被交换一次。因为取a[left]作为枢纽元,所以先要向左扫描,以便扫描结束时,a[i]<=a[left],此时交换a[i]和a[left]的值,就能把a[left]放到正确的位置。
例如,对于数组a(1 to 8) = (3,6,3,2,4,1,3,5,8),我们以a(1)为枢纽元对其进行划分,过程如下:
- 初始状态:i = left: j = right: p = a(left);
- j向左扫描,直到不满足条件i < j And a(j) >= p;
- i向右扫描,直到不满足条件i < j And a(i) <= p。然后如果满足条件i < j,就交换a(i)和a(j)的值,否则跳出外层循环。
- 由于满足条件i < j,所以交换a(i)和a(j)的值。
- 由于第3步过后,仍然满足条件i < j,所以再次进入循环,重复第2步的做法,j向左扫描,直到不满足条件i < j And a(j) >= p;
- 重复第3步的做法,i向右扫描,直到不满足条件i < j And a(i) <= p。此时i=j,跳出外层循环。
- 跳出循环后,交换a(i)和a(left)的值,将枢纽元放到正确位置。
- 划分结束后,我们顺利地以枢纽元为界,将数组a分割成了“小记录”和“大记录” 两个部分。接下来要做的就是递归调用函数,分别对左右两部分的元素快速排序。
相关代码如下:
Const n = 10
Dim a(1 To n) As Integer
Private Sub Command1_Click()
Call qsort_1(1, n)
End Sub
Sub qsort_1(ByVal left As Integer, ByVal right As Integer)
If left >= right Then Exit Sub
p = a(left) '选取a(left)作为枢纽元
i = left: j = right
Do While i < j
Do While i < j And a(j) >= p '先向左扫描
j = j - 1
Loop
Do While i < j And a(i) <= p '再向右扫描
i = i + 1
Loop
'将“不合群”的元素交换到另一侧
If i < j Then t = a(i): a(i) = a(j): a(j) = t
Loop
'将枢纽元放到正确位置
t = a(i): a(i) = a(left): a(left) = t
'递归调用函数分别对左右两部分的元素快速排序
Call qsort_1(left, i - 1)
Call qsort_1(i + 1, right)
End Sub
如果我们采用填补空位的方法,用覆盖旧值代替交换操作,可进一步提高效率。一开始把最左侧的枢纽元提取出来,则最左侧出现空位。向左扫描时把第一次遇到的小记录元素放到左侧的空位上,则该小记录处成为一个新的空位;然后向右扫描,把第一次遇到的大记录元素放到右侧的空位上,则该大记录所在处又成为一个新的空位。如此左右交替扫描,不断填补空位,并产生新的空位。最后形成的空位即枢纽元的最终位置。
我们再次以数组a(1 to 8) = (3,6,3,2,4,1,3,5,8)为例,对其划分过程分析如下:
- 初始状态:i = left: j = right: p = a(left),因为a(left)的值已经存储到p中,故a(left)的值可以被覆盖,即left处出现空位。
- j向左扫描,直到不满足条件i < j And a(j) >= p;
- 如果满足条件i < j,就执行a(i) = a(j),并将i右移一位,以避免重复判断。
- 由于满足条件i < j,所以执行a(i) = a(j),即用a(j) 覆盖a(i),此时j变成新的空位;将i右移一位,以避免重复判断。
- i向右扫描,直到不满足条件i < j And a(i) <= p;如果满足条件i < j,就执行a(j) = a(i),并将j左移一位,以避免重复判断。
- 由于满足条件i < j,所以执行a(j) = a(i),即用a(i) 覆盖a(j),此时i变成新的空位;将j左移一位,以避免重复判断。
- 再次进入循环,重复第2步的做法,j向左扫描,直到不满足条件i < j And a(j) >= p;
- 重复第3步和第4步的做法,a(j) 覆盖a(i),此时j变成新的空位;将i右移一位,以避免重复判断。
- 重复第5步的做法,i向右扫描,直到不满足条件i < j And a(i) <= p。此时i=j,跳出外层循环。
- 跳出循环后,执行a(i) = p,用枢纽元填补最后一个空位。
- 划分结束后,我们顺利地以枢纽元为界,将数组a分割成了“小记录”和“大记录” 两个部分。接下来要做的就是递归调用函数,分别对左右两部分的元素快速排序。
相关代码如下:
Private Sub Command3_Click()
Call qsort_2(1, n)
End Sub
Sub qsort_2(ByVal left As Integer, ByVal right As Integer)
If left >= right Then Exit Sub
p = a(left) '选取a(left)作为枢纽元
i = left: j = right
Do While i < j
Do While i < j And a(j) >= p '先向左扫描
j = j - 1
Loop
If i < j Then
a(i) = a(j) '填补空位,a[j]变成新的空位
i = i + 1 'i右移一位,以避免重复判断
End If
Do While i < j And a(i) <= p '再向右扫描
i = i + 1
Loop
If i < j Then
a(j) = a(i) '填补空位,a[i]变成新的空位
j = j - 1 'j左移一位,以避免重复判断
End If
Loop
a(i) = p '枢纽元填补最后一个空位
'递归调用函数分别对左右两部分的元素快速排序
Call qsort_2(left, i - 1)
Call qsort_2(i + 1, right)
End Sub
总结:交换操作需要执行3条赋值语句,移位(覆盖)操作只需要1条赋值语句,效率有所提升,当出现多次连续交换操作时,可考虑用移位代替交换操作。
需要本文word版的,可以加入“选考VB算法解析”知识星球参与讨论和下载文件,“选考VB算法解析”知识星球汇集了数量众多的同好,更多有趣的话题在这里讨论,更多有用的资料在这里分享。
我们专注选考VB算法,感兴趣就一起来!