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

本文来自我的大规模数据系统专栏《系统日知录》,专注存储、数据库、分布式系统、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)。就可以放心的对同一份数据进行反复读取、多次实验。

小结

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

题图故事

上海中山公园

相关推荐
Deepoch11 分钟前
Deepoc 大模型:无人机行业的智能变革引擎
人工智能·科技·算法·ai·动态规划·无人机
心平愈三千疾38 分钟前
通俗理解JVM细节-面试篇
java·jvm·数据库·面试
ai小鬼头1 小时前
百度秒搭发布:无代码编程如何让普通人轻松打造AI应用?
前端·后端·github
漂流瓶jz1 小时前
清除浮动/避开margin折叠:前端CSS中BFC的特点与限制
前端·css·面试
考虑考虑1 小时前
@FilterRegistration和@ServletRegistration注解
spring boot·后端·spring
一只叫煤球的猫1 小时前
🔥 同事混用@Transactional和TransactionTemplate被我怼了,三种事务管理到底怎么选?
java·spring boot·后端
我不吃饼干9 天前
鸽了六年的某大厂面试题:你会手写一个模板引擎吗?
前端·javascript·面试
我不吃饼干9 天前
鸽了六年的某大厂面试题:手写 Vue 模板编译(解析篇)
前端·javascript·面试
heimeiyingwang9 天前
【深度学习加速探秘】Winograd 卷积算法:让计算效率 “飞” 起来
人工智能·深度学习·算法
LyaJpunov9 天前
深入理解 C++ volatile 与 atomic:五大用法解析 + 六大高频考点
c++·面试·volatile·atomic