循环移除数组中的元素,真的有那么简单吗?

有这么一道题,移除数组中所有不符合要求的元素。

因为我目前是个iOS开发,所以这道题的内容我用OC语言来重新描述下:

假设有一个NSMutableArray,里边存放了nPerson的实例,Person里有个属性age,现在要求你写个方法,移除数组中所有age < 18Person

乍一看,很简单对吧?

一般的写法


遇到类似问题,我们一般都是直接写个循环,然后在循环里边进行判断,移除不符合要求的元素,大致的写法如下:

for (int i = 0; i < arr.count; i++) {
    Person *person = arr[i];
    if (person.age < 18){
        [arr removeObjectAtIndex:i];
        I--;
    }
}

这里有一点需要注意的是,每次移除数组的元素之后,当前索引后边的元素索引都会变,所以这时候需要将i减一,否则会有部分元素无法遍历到。

看上去完成了要求,可是如果你这么写的话,我只能给你打一个60分,因为完成了任务,所以及格了,但仅仅完成了任务,所以只是及格。

这样的写法,只考虑到了完成任务本身,而没考虑到算法的效率问题。上边的写法,时间复杂度是O(n²),而我们的目标是,要写出时间复杂度为O(n)的算法。

等等...上边的写法只有一层循环,主要的操作就是移除元素,时间复杂度应该是O(n)啊,是不是那里算错了?

那么问题出在哪了?我们知道,当我们在移除数组的元素之后,实际上我们是需要循环的将之后的元素全都往前移一位的,如下图所示:

移除当前元素后,需要将之后的元素向前移动一位

代码里之所以没有体现出来,是因为OCNSMutableArray已经帮我们做了这个工作,所以这样的写法实际上是有两层循环的,假设数组里有n个元素,每个元素都是需要移除的,那么第1次需要移动n次(移除当前元素,前移剩下的n-1个元素),第2次需要移动n-1次...第n次需要移动1次。

这是典型的前n项和公式,计算结果是(1+n)*n/2,所以时间复杂度是O(n²)

另一种写法


我们很容易会想到另一个比较巧的写法,就是倒着循环:

for (int i = arr.count - 1; i >= 0 ; i--) {
    Person *person = arr[i];
    if (person.age < 18){
        [arr removeObjectAtIndex:i];
    }
}

这样写的好处是,可以避免索引乱掉的问题,因为,当你移除掉当前元素之后,影响的是后边的元素,而因为你是倒着循环的,后边的元素你已经遍历过了,所以不会有影响。

但最关键的是,这种写法看上去要比上一个方法效率高点,因为是倒着遍历的,所以当我们移除当前元素,将之后的元素向前移动的时候,由于后边不符合要求的元素已经被我们移除了,所以避免了没有必要的移动。反过来,正循环每次移除元素后,需要把后边的所有元素前移,即便有些元素也是要移除的不符合要求的,也不例外。

举个例子就是,如果数组里的所有元素都是需要移除的,正循的时间复杂度是O(n²),而倒循环则达到了惊人的O(n),因为后边的元素都已经移除了,不需要进行前移操作。

如果你这么写,我会给你65分,毕竟这种写法解决了索引的问题,比正循环少了一个i--的操作,多少会有些效率的提高,可也只能比前一种方法多5分,因为它仍然没有突破O(n²)的坎儿,并且,至少在iOS中,这两种写法几乎是没有任何区别的。

我们刚才认为的效率高点,其实并不存在,因为,iOS的数组已经针对这样的问题进行了优化。

NSMutableArray的底层实现


首先,NSMutableArray的底层是C数组;

NSMutableArray使用了环形缓冲区的概念,简单来说就是,数组还是那个数组,但是firstObject可以在C数组的任何位置。

NSMutableArray内部有三个属性:
_size:数组大小。
_used:已使用部分大小。
_offsetfirstObject所在的位置。

假设有这么一个数组:


现在,我们删除第4个元素:

这个比较好理解,接下来我们删除第1个元素,按照正常逻辑,应该是这样的:

但实际上,NSMutableArray对增删操作的内存移动做了优化,它会进行计算,然后以最小的内存移动代价来完成这次移除操作,所以实际上是这样的:

现在我们继续移除1,2,3位置的元素,然后再在6,7,8,9位置上添加新的元素:

如果我们此时再向数组尾部添加一个元素,则会变成这样:


这就是环形缓冲区的概念。

跨过O(n²)的坎儿


现在我们明白了,因为iOS数组底层的优化,前两种方法的时间复杂度是一样的,不管是从哪头开始遍历,NSMutableArray都会用最小的开销来移动元素。

因此,我们想到了第三种实现方式,创建一个新的数组,将原数组中符合要求的元素移动到新的数组中,并将原数组销毁,这样,每次循环只需要将元素移动到另一个数组,再也不用担心内存移动所带来的消耗了!代码大致如下:

NSMutableArray *newArray = [NSMutableArray arrayWithCapacity:originArray.count];
for (int i = 0; i < originArray.count ; i++) {
    Person *person = originArray[I];
    if (person.age >= 18){
        [newArray addObject:person];
    }
}

好了,现在时间复杂度是O(n)了!是不是很有成就感!然而,这个O(n)是有代价的...

虽然时间复杂度达到O(n)了,但是相应的,空间复杂度也从O(0)变成O(n)了。所以,这只是牺牲空间换时间,而时间和空间的重要性,会根据实际的场景而变化,所以并不存在谁一定比谁重要(虽然大部分情况下大家认为时间更重要)。

但作为第一个达到O(n)的算法,我们可以将之作为备选方案,这里标记为空间换O(n)算法

降低时间复杂度和空间复杂度的重要性


也许有些人会不以为然,时间复杂度和空间复杂度真的有那么重要么?就这几个元素,真的有必要搞得这么复杂?

其实是很有必要的,如果数组里只有十几个元素,可能O(n)O(n²)的差距不大,但如果有几万,乃至于上百万个元素呢?为了节省时间而需要多开辟这么大的空间是不理智的,而为了节省空间让计算的时间成指数倍增加也是不能接受的。

也许还有人会说,作为客户端,感觉日常开发中,即便遇到了这样的需求,也不会真的需要操作大量的数据。

我想说的是,作为一个优秀的程序员,我们不能放过任何一个微小的细节,虽然优化过后带来的收益可能微乎其微,但积少成多,也许就能看到明显的效率提升,就能改善用户的体验。

退一步说,万一以后我们转岗做其他端了呢?对吧~

下面我们来实际模拟下,看一下O(n)O(n²)的差距到底有多大。

我们随机生成十万个Person,分别用第一个算法和空间换O(n)算法来移除,看看时间上能差多少:

可以看到,O(n)的算法只用了0.012秒,而O(n²)则用了0.206秒,时间上相差了16.9倍!

O(n)算法的空间开销则达到了781KB

那么,有没有时间复杂度O(n),空间复杂度O(0)的方案呢?有的!

快速排序


在讲下一个方案之前,我先讲一下快速排序,可能大家已经猜到了,突破O(n²)的关键,就在快排的核心思想中。

快排的核心思想是,找一个基准元素,将比基准元素大的元素放到基准元素右边,将比基准元素小的元素放到基准元素左边,这样一圈下来,左边的部分就是全都小于基准元素的元素,右边是全都大于的。然后再对左右两边的部分重复上述过程,直到最后一个元素。下面进行图解说明:

假设我们有个未排序的数组:


我们需要定义两个变量leftright,分别代表数组的第一个元素和最后一个元素的索引值,然后,将第一个元素18作为基准元素


关于基准元素的选择,其实是有策略的,这里为了简单,直接取第一个元素。

接下来,right从后向前遍历,找到第一个小于基准元素的元素并记录,left从前向后遍历,找到第一个大于基准元素的元素并记录(注意,rightleft遍历的先后顺序会影响到之后的处理逻辑,大家可以思考下为什么),然后,交换这两个位置的元素:


重复这一过程,直至leftright相遇:

然后将基准元素left(right)所在的元素调换位置:


这样,数组就被基准元素分割成了两个部分,接下来只需要重复上边的过程,直到排序完毕:

真·跨过O(n²)的坎儿


知道了快排的原理,我们就可以对我们的算法进行升级了,不过,毕竟我们的需求不是排序,所以实现上会稍微有些不同。

仔细一想,本题中的18岁就是快排中的基准元素,按照这个思想,假设我们有个数组:

我们定义两个变量leftright,分别指向数组的头和尾:

image.png

然后left向后遍历,找到第一个不符合要求的Person,right向前遍历,找到第一个符合要求的Person(这里的先后顺序依然会影响后边的逻辑):

然后交换这两个Person:


重复这个过程,直到leftright相遇:

此时,从left(right)到数组末尾的元素,就全都是不符合的了,然后我们将这些不符合的元素全部移除,得到最终结果:

大致代码如下:

- (void)removeInconformityElementOfArray:(NSMutableArray *)arr{
    int left = 0;
    int right = arr.count - 1;
    do {
        while (left <= right) {
            Person *person = arr[left];
            if (person.age < 18){
                //找到了不符合的元素
                break;
            }
            left++;
        }
        while (left < right) {
            Person *person = arr[right];
            if (person.age >= 18){
                //找到了符合的元素
                [arr exchangeObjectAtIndex:left withObjectAtIndex:right];
                left++;
                if (left != right - 1) {
                    right--;
                }
                break;
            }
            right--;
        }
    } while (left < right);
    [arr removeObjectsInRange:NSMakeRange(left, arr.count - left)];
}

不需要多余的内存移动,只需要简单的元素交换,时间复杂度O(n)。没有多余的内存开销,空间复杂度O(0),现在,我们找到了第二个备选方案,我们标记为不稳定O(n)算法

是的,这个算法是不稳定的。

排序算法的稳定性指的是,排序结束后,相等元素的相对顺序是否保持不变。如果不变,那么我们说这个排序算法是稳定的。

我们知道,快速排序是不稳定的,因为交换的过程,可能会导致相同的元素的位置发生变化,如下图所示:


交换过后,两个“14”的相对位置发生了变化

因此,上边的算法也是不稳定的,用上边的算法操作后的数组,里边元素的顺序会乱掉。

试想,如果这是n个人在排队买票,现在临时决定让未成年人去另一个窗口排队了,所以需要在当前队列中移除未成年人,可是当移除了所有的未成年人之后,队伍乱掉了,刚才排第一的人现在在队尾😱,是不是很可怕!

真·稳定的·跨过O(n²)的坎儿


那么,如何写出一个稳定的O(n)算法呢?

快排虽然不稳定,但给我们提供了正确的思路,我们能不能在快排的思想基础上,换个稳定的实现方式呢?答案是肯定的。

既然从两边往中间走不稳定,那么我们可以尝试都从一边走,不符合标准的从左边开始找,符合的也从左边找,这样是不是就避免了不稳定性呢?让我们来看下。

首先,依然是那个数组:


image.png

这次,我们不用leftright了,我们定义两个变量retain(保留)remove(移除),我们先将remove设置为0

然后向右遍历,找到第一个不符合要求的元素:


因为remove之前的一定都是符合要求的元素,所以直接将retain的初始值赋值为remove + 1

然后找到第一个符合要求的元素:


交换这两个元素:


重复上述步骤,直到retain完成遍历:

移除remove以及之后的所有元素,得到最终结果:

代码大致如下:

- (void)removeInconformityElementOfArray:(NSMutableArray *)arr{
    //记录需要移除的元素
    int remove = 0;
    for (int i = 0; i < arr.count; i++) {
        Person *person = arr[i];
        if (person.age >= 18){
            [arr exchangeObjectAtIndex:remove withObjectAtIndex:i];
            remove++;
        }
    }
    //移除不符合的元素并结束
    [arr removeObjectsInRange:NSMakeRange(remove, arr.count - remove)];
}

这是最后一个备选方案,我们标记为稳定O(n)算法

三个备选方案的对比


现在,我们再来模拟一下,还是100000Person,用O(n²)算法和三个备选方案作对比。

这次我们对生成Person实例的策略进行了调整,弃用了完全随机的方案,取而代之的是,将奇数索引的age设置为17,偶数索引的设置为19,这样,就能保证生成的实例中,有一半是需要移除的。

这么做的原因是,空间换O(n)算法主要的时间开销是将符合的元素移动到新的数组,而另外的两个O(n)算法是将不符合的元素交换到数组末尾并移除。因此,需要移除的元素越少,空间换O(n)算法执行的时间反而越长,反之亦然,为了得到更准确的数据,符合不符合的元素数量要相等。

运行后的结果如下:

我们可以看到,O(n)O(n²)的差距还是很显而易见的,不过,三个O(n)算法的耗时也是有一些差别的,下面我详细的分析下。

空间换O(n)算法是最快的,因为牺牲了空间,所以,该算法主要的时间开销是,将符合的元素放到新的数组,相当于只做了一次赋值操作,所以是最快的。

不稳定O(n)算法稍慢,因为该算法除了移除不符合的元素,还需要交换元素的位置,比空间换O(n)算法多了一些操作,所以会有一些额外的开销,不过差的并不多,大概多出了6.4%左右。

稳定O(n)算法最慢,比空间换O(n)算法大概多出了11.9%的时间,因为该算法相当于是在不稳定O(n)算法的基础上,又做了额外的元素交换。不稳定O(n)算法相当于是牺牲了稳定性来换取时间上的效率,下面举例来说明:

还是以本题为例,如图所示的数组,不稳定O(n)算法是直接交换不符合的元素11与最后一个元素19,只有一次交换就完成了任务,但是相应的,数组元素的顺序会乱掉,所以不稳定。

稳定O(n)算法在找到了不符合的元素11之后,要先找到其后的第一个符合的元素50,并与之交换,然后继续寻找后边的其他符合的元素交换,直到结束。本例中进行了四次交换,正是那多出的三次交换,保证了算法的稳定性,但同时时间上也会稍慢一些。

时间复杂度和空间复杂度的取舍


那么,以上三个算法哪个最好呢?如果数组里的元素数量较少,我们可能就不太关心空间开销,空间换O(n)算法无疑是最棒的。对于大量数据的操作,就要看具体的使用场景(需求)了,那么你是否愿意牺牲11.9%的时间来节省大量的空间开销呢?如果对稳定性没要求,付出的代价可以更少。

又或者,当前场景下,需要移除的元素不会太多,那么看上去速度最快的空间换O(n)算法反而拖了后腿。

所以,还是要根据实际情况具体分析的。

结论就是,这三个算法都很优秀~在不同的使用场景,我都可以给你85分!

还想得更高的分怎么办?继续往下看~

继续优化


虽然这个算法已经很优秀了,但还是有一些问题,比如:

  1. 没有对入参的异常情况进行处理,比如输入的是不可变数组,或者根本不是个数组。
  2. 入参的数组没有判空。
  3. 获取数组元素后,没有判断元素类型,如果数组有非Person的元素,就会crash。
  4. 算法不通用,耦合了业务逻辑,只适用于判断Personage
  5. 没有运用OC的特性,让算法更独立,架构更清晰。

下面我们针对这些问题进行优化。

添加对入参异常情况的处理:(+1分)

if (![arr isKindOfClass:NSMutableArray.class]){
    return;
}

添加数组判空逻辑:(+1分)

if (!arr.count){
    return;
}

判断元素类型:(+1分)

if (![obj isKindOfClass:Person.class]){
    //遇到数据类型不匹配问题,根据业务需求返回
    return YES;
}
return ((Person *)obj).age < 18;

算法解耦,我们可以仿照NSMutableArray的排序方法进行优化,这里使用Block的方式,修改后的方法名如下:(这个得加2分!)

- (void)removeInconformityElementOfArray:(NSMutableArray *)arr
                            compareBlock:(BOOL (^)(id obj1,id obj2))compareBlock

相应的,原来方法里判断的地方就变成了这样:

if (judgmentBlock(arr[remove])){
    //找到了不符合的元素
    break;
}

调用大概是这个样子的:

[self removeInconformityElementOfArray:arr withJudgmentBlock:^BOOL(id obj) {
    if (![obj isKindOfClass:Person.class]){
        //遇到数据类型不匹配问题,根据业务需求返回
        return YES;
    }
    return ((Person *)obj).age < 18;
}];

下面重点说下运用OC特性这一点,我们知道,目前的方法是定义在我们需要操作数组的类里,显然这个方法放在这里是不合适的,如此通用的一个方法,应当服务更多的类才是。

于是我们定义了一个工具类XXXUtil,将这个方法变成了类方法,这样,需要的地方就都可以用了...但是这样还是不够好。

不好的原因有很多,比如,这个类应该放在哪?如何归类?这个类里还可以放一些什么方法?最关键的是,别人需要用的时候,他怎么知道有没有这样的方法?应该去哪个类里找?

这个时候,我们想到了OC的一个特性Category,我们可以给NSMutableArray写一个Category,将这个方法放到Category当中,这样,我们就可以把它归类到CommonLibCategory分类当中,别人要用到了相似的功能,可以优先去找NSMutableArray的分类,看看有没有类似的功能,思路清晰,分类准确,寻找方便,架构清晰,很合理对不对~

下面我们继续对方法进行优化,首先我们创建个分类NSMutableArray+LSYRemoveElement,然后将方法修改后放到这个类中。最终的代码如下:

- (void)lsy_stabilizeRemoveInconformityElementWithJudgmentBlock:(BOOL (^)(id obj))judgmentBlock{
    if (![self isKindOfClass:NSMutableArray.class] || !self.count){
        return;
    }
    //记录需要移除的元素
    int remove = 0;
    for (int i = 0; i < self.count; i++) {
        if (!judgmentBlock(self[i])){
            [self exchangeObjectAtIndex:remove withObjectAtIndex:i];
            remove++;
        }
    }
    //移除不符合的元素并结束
    [self removeObjectsInRange:NSMakeRange(remove, self.count - remove)];
}

调用代码大致如下:

[arr lsy_removeInconformityElementWithJudgmentBlock:^BOOL(id  _Nonnull obj) {
    if (![obj isKindOfClass:Person.class]){
        //遇到数据类型不匹配问题,根据业务需求返回
        return YES;
    }
    return ((Person *)obj).age < 18;
}];

到此,优化完毕,我可以给你92分!

那么,还有可以优化的地方嘛?

其实还有...


emmm...别嫌烦啊,有时候,看着简单的事情,做起来确实可能会很麻烦。

看上去,该考虑的都考虑到了,那么还差什么呢?

我们还没有考虑线程安全问题啊!!!

如果全程只在一个线程操作,那么这个方法已经非常完美,可万一在移除的过程中,其他线程对数组做了增删改交换等操作呢?或者其他线程也调用了这个方法处理同一个数组会怎么样?所以线程安全是非常必要的。

保证线程安全的方式有很多种,具体如何实现,我这里就不举例了,因为有关多线程的东西太多了,以后可以慢慢讲。

还有最后一点...


最后一点要考虑的就是,如果数组里的元素太多,操作太耗时,就会导致卡顿。所以我们可以提供个同步异步的选项,由使用者来决定是否要在子线程执行。

到此,我可以给你95分了!少给你5分是怕你骄傲!

Demo我已经上传到了GitHub,有兴趣的小伙伴可以下下来看看~

写在最后的话


刚工作的时候,我的一个领导曾经对我说过这样一句话:

不管你觉得你写的代码有多棒,也许你现在觉得已经是最好了,但三个月后再看,你依然能够找到很多可以优化的地方。

然后我用我的懒惰和退步向他证明了,他错了!

当然不是!其实是我明白了他这句话的意思,所谓没有最好,只有更好,所谓学无止境。我们现在觉得自己写的代码无可挑剔,是因为以我们现在的知识水平,还没找到更好的写法,随着我们技术水平的提高,我们一定能写出更好的代码~

所以,少给5分不是怕骄傲,是因为确实不知道满分是什么样子的。

最主要的是,万一有一天,有人用更好的写法反驳了我,我就可以理直气壮地说,“我不是没给自己打满分么😏”

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,294评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,493评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,790评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,595评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,718评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,906评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,053评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,797评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,250评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,570评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,711评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,388评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,018评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,796评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,023评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,461评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,595评论 2 350

推荐阅读更多精彩内容