写对代码的利器——“循环不变性”

本文来自我的大规模数据系统专栏《系统日知录》,专注存储、数据库、分布式系统、AI Infra 和计算机基础知识。欢迎订阅支持,解锁更多文章。你的支持,是我前行的最大动力。

初学者在构建复杂代码时,往往会吃不准------我这样写对吗?本文就从"不变性"(invariants)的角度,给大家一些增加信心的"打开方式"。

循环不变性

如果大家看过算法导论,应该对这个词不陌生。粗略来说,在算法中,循环不变性(loop invariants)指的是在迭代三个关键环节(初始化、迭代中、结束时)上维持某种性质的不变。

以三种基本排序(冒泡排序、选择排序和插入排序)为例:

三种基本排序算法

我们将整个数组分为两部分,前半部分有序,后半部分无序。则只要我们能够保持前半部分一直有序:

  1. 初始化:只有一个元素,一定有序。

  2. 迭代中:每次挪入一个新元素,仍然保持前半部分有序:

  3. 冒泡:每次从无序集合中出一个最小的值,放到有序集后面,则有序集一定仍然有序。

  4. 选择:每次从无序集合中出一个最小的值,交换到有序集最后,则有序集仍然有序。

  5. 插入:每次将边界处的元素插入到有序集中合适的位置,保持其仍然有序。

  6. 结束时:可得,有序集扩张到了整个数组,即我们排好序了。

通过在迭代的三个环节中保持有序集的一直有序,我们可以很有信心:我们最后得到的数组一定是有序的。聪明的你可能已经感觉到了,这不就是数学归纳法吗?对,只不过数学归纳法可以对任意规模进行归纳,而在算法迭代中,通常有个结束条件。

这其实有一种"拆解"的思想在里面。我们人脑通常很难记太多的上下文,所以通常会通过拆解的方法来降低所面临问题的复杂度。对于循环不变性来说,就是找到一种解决该问题的合适性质,然后通过在循环的三阶段中维持该性质,我们就不至于陷入海量的细节中去出不来。

排序算法相对比较简单,对其妙用可能还体会不深,下面就用一道 LeetCode 上稍微复杂一点的算法题:Sort Colors 为例来再次体会下循环不变性的运用。题目如下:

text 复制代码
给定一个包含红色、白色和蓝色、共 n 个元素的数组 nums ,原地对它们进行排序,
使得相同颜色的元素相邻,并按照红色、白色、蓝色顺序排列。

我们使用整数 0、 1 和 2 分别表示红色、白色和蓝色。

看到这里,如果有条件,可以先停下来,自己做下这道题,要求时间复杂度为 O(n),空间复杂度为 O(1) 。

cpp 复制代码
void sortColors(vector<int>& nums) {
      int r = 0;           // [0, r) is red
      int b = nums.size(); // [b, nums.size()) is blue
      
      int i = 0;           // [r, i) is white, and [i, b) is unsorted
      while (i < b) {
          if (nums[i] == 0) { // red
              swap(nums[r++], nums[i++]);
              // r++, append the red to the red set, then expand the r
              // i++, we get a white from the former nums[r] which is white
              // then we should expand i
          } else if (nums[i] == 2) {
              swap(nums[--b], nums[i]);
              // we put an blue in the front of the blue set after expand
              // we get a unsorted one from the former nums[b], so i stays
          } else {
              ++i;
              // expand white set and shrink unsroted sort
          }
      }
  }

我们很容易想到多指针法,且最后不加注释的代码非常简单,寥寥数行。但是你会发现:

  1. 初始化条件很难写对

  2. 迭代时指针是否增减很难得当

  3. 结束条件等于是否加也陷入纠结

我以前常用特殊 case 来确定上述三个点的写法,但是发现在这道题中失灵了,太多 case 要想了,老容易忘记一些点、按下葫芦浮起了瓢。

但如果,我们使用上面提到的循环不变性,使用指针将数组分为几个区间,且全部左闭右开,在这个:

text 复制代码
[0, red):红色

[red, i):白色
[i, blue):未定

[blue, n):蓝色

当然,这里用了一个技巧,将迭代指针 i 放到 red 和 blue 间,其实放到 blue 之后是最符合直觉的,但是在维持不变性时会增加很多交换。这技巧倒也符合直觉:将红色和蓝色往两边扔,中间自然剩下白色了。

找到了上述需要维持的"不变性",我们在初始化、迭代维持和终止条件确定方面就非常"**有法可依"**了。可以看上面代码注释了解更多细节,这里就不赘述了。

其他的不变性

除了循环不变性之外,我们在工程中其实也常用到不变性的思想,只是我们没有往这边去靠。

接口

接口通常包含一组操作集,这些操作集就定义了某种"性质"。而无论接口之下做何种实现,都要保证提供这些操作,这便是要维持"不变性"。有了这种不变性保证,所有接口的依赖方,就可以不必担心你如何实现,只需要面向接口进行编程即可。这给了我们将来进行"平替"的极大灵活性。

比如,使用 fuse 来实现一套用户态文件系统,不管底层如何实现(即使实现为分布式的),最终都可以 mount 到 linux 目录树中。

测试

测试通常包括一些用例集,这些用例集定义了我们代码需要满足的"行为"。如果测试用例覆盖足够完善,我们在进行代码重构时,即使进行了大幅度的修改,但只要保证测试都能跑过,我们就很有信心------我们的重构没有大问题。即,这些完善的测试集给我们的代码逻辑保证了逻辑上的"不变性"。

从某种程度上来说,测试从外部定义了我们系统的"边界"。

数据

众所周知,并发编程、分布式编程都很难写对。但如果我们能保证数据的"不变性"(当然,这里有点偷换概念了,英文中其实是 immutable)。就可以放心的对同一份数据进行反复读取、多次实验。

小结

通过维持 "不变性",可以让我们隔离复杂度------就像森林中的防火带,阻断火势蔓延。其实,这就是编程中抽象封装思想的另一个侧面。这些工程化的东西,本质上是为了适应人类的"认知方式"造出来的,让我们可以大规模的协作,来构建宏伟的建筑;让我们的知识可以代际累积,不断拓展科学的前沿。

题图故事

上海中山公园

相关推荐
陈随易3 分钟前
兔小巢收费引发的论坛调研Node和Deno有感
前端·后端·程序员
木小同8 分钟前
JAVA基础之NIO
面试·java基础·nio
聪明的墨菲特i9 分钟前
Django前后端分离基本流程
后端·python·django·web3
shymoy29 分钟前
Radix Sorts
数据结构·算法·排序算法
风影小子38 分钟前
注册登录学生管理系统小项目
算法
黑龙江亿林等保40 分钟前
深入探索哈尔滨二级等保下的负载均衡SLB及其核心算法
运维·算法·负载均衡
lucy1530275107943 分钟前
【青牛科技】GC5931:工业风扇驱动芯片的卓越替代者
人工智能·科技·单片机·嵌入式硬件·算法·机器学习
hlsd#1 小时前
go mod 依赖管理
开发语言·后端·golang
杜杜的man1 小时前
【go从零单排】迭代器(Iterators)
开发语言·算法·golang
陈大爷(有低保)1 小时前
三层架构和MVC以及它们的融合
后端·mvc