前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >写对代码的利器——“循环不变性”

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

作者头像
木鸟杂记
发布2024-03-22 17:28:44
930
发布2024-03-22 17:28:44
举报
文章被收录于专栏:木鸟杂记

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

循环不变性

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

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

三种基本排序算法

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

  1. 初始化:只有一个元素,一定有序。
  2. 迭代中:每次挪入一个新元素,仍然保持前半部分有序:
    1. 冒泡:每次从无序集合中出一个最小的值,放到有序集后面,则有序集一定仍然有序。
    2. 选择:每次从无序集合中出一个最小的值,交换到有序集最后,则有序集仍然有序。
    3. 插入:每次将边界处的元素插入到有序集中合适的位置,保持其仍然有序。
  3. 结束时:可得,有序集扩张到了整个数组,即我们排好序了。

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

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

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

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

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

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

代码语言:javascript
复制
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 要想了,老容易忘记一些点、按下葫芦浮起了瓢。

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

代码语言:javascript
复制
[0, red):红色

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

[blue, n):蓝色

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

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

其他的不变性

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

接口

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

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

测试

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

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

数据

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

小结

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

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2024-03-18,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 木鸟杂记 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 循环不变性
  • 其他的不变性
    • 接口
      • 测试
        • 数据
        • 小结
        相关产品与服务
        腾讯云服务器利旧
        云服务器(Cloud Virtual Machine,CVM)提供安全可靠的弹性计算服务。 您可以实时扩展或缩减计算资源,适应变化的业务需求,并只需按实际使用的资源计费。使用 CVM 可以极大降低您的软硬件采购成本,简化 IT 运维工作。
        领券
        问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档