在之间的两篇文章中,我们着重讲了顺序表及顺序表的实现。今天这篇文章我们将简单讲解关于顺

序表的三个算法题。这三个题也都属于力扣上的经典例题。

1.例题1:移除元素

 例题来源(力扣) : https://leetcode.cn/problems/remove-element/description/
这是一道数组操作算法题,核心任务是:
 
可以简单理解为 : 给你一个装着数字的“盒子”(数组  nums )和一个特定数字( val ),要你在这
个“盒子”里直接把所有等于 val 的数字去掉,并且把剩下的数字往前排,最后告诉别人剩下多少个

不等于 val 的数字(返回  k )。
 
比如盒子里是  [3,2,2,3] ,要去掉 3,操作后盒子前两位变成  [2,2] ,返回剩下 2 个。


 
方法一 : 嵌套循环移位法

int removeElement(int* nums, int numsSize, int val) 
{
    int i,j;
    for(i = 0;i<numsSize; )
    {
        if(nums[i]==val)
        {
            // 当找到要移除的元素时,将后面元素逐个向前移位
            for(j = i;j<numsSize-1;j++) 
            {
                nums[j] = nums[j+1];
            }
            numsSize--; //覆盖掉当前要移除的元素,同时将数组有效长度numsSize减1
        }
        else
        {
            i++; 
        }
    }
    return numsSize;
}

思路:

- 外层 for 循环遍历数组,循环条件中 i 不直接自增,根据元素是否等于 val 决定后续操作。

- 当发现 nums[i] 等于 val 时,通过内层 for 循环将 i 位置之后的所有元素依次向前移动一

,覆盖掉当前要移除的元素,同时将数组有效长度 numsSize 减 1;若 nums[i] 不等于

val,则  i  正常自增,继续遍历下一个元素。

- 最终返回的 numsSize 就是数组中不等于 val 的元素数量,且数组前 numsSize 个元素已更

新为不含 val 的结果。
 
时间复杂度:
 
- 最坏情况下,根据两个 for 循环, 可以得出时间复杂度为 O(n^2) ,其中 n 是数组 nums 的初

始长度 。

- 最好情况下,数组中没有等于 val 的元素,外层循环只需遍历一次数组,时间复杂度为

O(n) 。

取最坏的结果,所以第一种解法的时间复杂度为 O(n^2)。
 
空间复杂度:

- 整个过程只使用了有限的几个额外变量( i 、 j  等 ),没有额外开辟大量数据存储空间,

空间复杂度为 O(1) ,属于原地操作。

方法二 : 借助数组法

int removeElement(int* nums, int numsSize, int val) 
{
    // 动态分配一个与原数组大小相同的临时数组,处理 numsSize 为 0 的情况避免非法
    int* tmp = (int*)malloc(numsSize * sizeof(int));   
    if(tmp == NULL)
    {
        return 0;
    }
    int index = 0;  // 记录新数组 tmp 的下标,用于存放不等于 val 的元素
    for(int i = 0;i<numsSize;i++)
    {
        if(nums[i]!=val)
        {
            tmp[index] = nums[i];
            index++;
        }
    }
    // 将临时数组中有效元素(不等于 val 的元素)拷贝回原数组前 index 个位置
    for(int i = 0;i<index;i++)
    {
        nums[i] = tmp[i];
    }
    free(tmp);  // 释放临时数组内存,避免内存泄漏
    return index;
}

思路:
 
- 首先动态分配一个和原数组 nums 大小相同的临时数组 tmp,用于存储不等于 val 的元

素。若内存分配失败( tmp == NULL  ),直接返回 0。


- 遍历原数组  nums  ,当遇到元素不等于  val  时,将其存入临时数组 tmp 中,同时

用 index  记录  tmp  中有效元素的个数(即下标 )。


- 遍历结束后,把临时数组  tmp  中存储的有效元素(共  index  个 )拷贝回原数组nums 的

前 index 个位置,最后释放临时数组 tmp 的内存,避免内存泄漏,返回 index  ,即原数组中

不等于 val 的元素数量。
 
时间复杂度:
 
- 遍历原数组  nums  是一个 O(n) 的操作( n  为数组初始长度  numsSize  ),将临时数组元

素拷贝回原数组也是一个 O(k) 的操作( k  为不等于  val  的元素数量,最大为  n  ),总的

时间复杂度为 O(n) ,因为 O(n + k) 中  k  最大不超过  n  ,可简化为 O(n)
 
空间复杂度:
 
- 额外动态分配了一个大小为  numsSize  的临时数组  tmp  ,所以空间复杂度为 O(n) 

我们可以明确的观察到,第二种方法的时间复杂度比第一种方法的时间复杂度更简单,但是空间复
杂度比第一种解法的空间复杂度更复杂,所以第二种方法比第一种方法就是采用了空间换时间的做

方法三 :  双指针法(推荐的高效原地算法 )

int removeElement(int* nums, int numsSize, int val) 
{
    int dst = 0,src = 0; 
    while(src < numsSize)
    {
        // 当 src 指向元素不等于 val 时,将其赋值到 dst 位置,dst 后移
        if(nums[src] != val) 
        {
            nums[dst] = nums[src];
            dst++;
        }
        src++; 
    }
    return dst;
}

思路:
 
- 定义两个指针(这里用变量 dst 和 src 模拟指针功能 ),src 用于遍历原数组 nums  ,逐个

检查元素; dst  用于标记新数组(即处理后不含 val 的数组部分 )的最后一个位置。


- 遍历过程中,当  nums[src]  不等于  val  时,说明该元素是需要保留的,将其赋值到

nums[dst] 的位置,然后 dst 自增 1,指向下一个要填充的位置;不管 nums[src] 是否等于

 val ,src 都要自增 1 ,继续遍历下一个元素。


- 当  src  遍历完整个数组( src >= numsSize  )时, dst  的值就是原数组中不等于  val  的

元素数量,且原数组前  dst  个元素已经是处理后不含  val  的结果。
 
时间复杂度:
 
- 只需遍历一次原数组  nums  , src  从 0 走到  numsSize - 1  ,循环执行  numsSize  次,

所以时间复杂度为 O(n) ,其中  n  是数组  nums  的初始长度 。
 
空间复杂度:
 
- 只使用了常数个额外变量( dst 、 src  ),没有额外开辟大量数据存储空间,空间复杂度

为 O(1) ,属于高效的原地算法。

通过第三种方法,我们可以观察到第三种方法比前两种方法都更加简单。时间复杂度比第一种方法
更简单,空间复杂度比第二种方法更简单。
在实际开发中,优先推荐 双指针法 ,它在时间和空间复杂度上都更优,能高效解决问题;嵌套循
环移位法在数据量小的时候可以用,但数据量大时性能不佳;借助额外数组法一般不是最优选择,
除非有特殊场景需求(比如不能修改原数组内容,只是临时拷贝处理等,但本题要求原地操作,它
其实不太契合题目 “原地” 要求,只是一种思路 ) 。

2.例题2:删除有序数组中的重复项

例题来源(力扣) : https://leetcode.cn/problems/remove-duplicates-from-sorted-array/description/
这道题也可以使用双指针的解题方法来写。并且使用双指针能够最大程度的简化时间和空间复杂
度。下面我们来看一下双指针的具体解法。
给非严格递增有序数组 nums ,原地删除重复元素,使每个元素仅出现一次,保持相对顺序,返回
新长度 k ,且需保证 nums  前 k  个元素为去重后的唯一元素。

方法 : 双指针解法

int removeDuplicates(int* nums, int numsSize) {
    // dst 初始指向第 1 个唯一元素应在的位置(初始为 0)
    int dst = 0, src = dst + 1; 
    // src 遍历整个数组,找后续元素
    while (src < numsSize) { 
        // 条件 1:当前 src 元素与 dst 元素不同(需保留 src 元素)
        // 条件 2:++dst != src(尝试移动 dst 后,判断是否与 src 位置重叠)
        if (nums[src] != nums[dst] && ++dst != src) { 
            // 将 src 元素放到 dst 位置,保证前 dst+1 个元素唯一
            nums[dst] = nums[src]; 
        }
        // src 继续向后遍历
        src++; 
    }
    // dst 是最后一个唯一元素的下标,长度为下标 + 1
    return dst + 1; 
}

思路(以  nums = [1,1,2]  为例 ):
 
1. 初始状态: dst = 0 (指向第一个  1  ), src = 1 (指向第二个  1  )。

2. 第一次循环: nums[src] == nums[dst] (都是 1 ),足 nums[src]!=nums[dst] , src++ →

src = 2 。

3. 第二次循环: nums[src] = 2  ≠  nums[dst] = 1 ,执行  ++dst ( dst = 1  ),判断  dst !=

src ( 1 != 2  → 成立 ),执行  nums[1] = 2 ,数组变为  [1,2,2] ; src++  →  src = 3 (退出

循环 )。

4. 返回结果: dst + 1 = 2 ,与题目要求一致。
 
复杂度分析:
 
时间复杂度:
 
- 核心逻辑: src  从  1  遍历到  numsSize - 1 ,仅遍历数组一次。

- 无论数组多长,循环次数为  numsSize - 1 ( src  移动次数 ),因此时间复杂度为

 O(n)( n  为numsSize ,即数组长度 )。
 
空间复杂度:
 
- 函数仅使用  dst 、 src  两个额外变量,未动态分配内存(如  malloc  )。

- 额外空间占用与数组长度无关,始终为常数,因此空间复杂度为  O(1) (原地算法 )。

对于本地来说,双指针法就是最优的解法。无论是从时间复杂度还是从空间复杂度来说,都是最优
答案。希望大家能够好好理解这道题,好好理解双指针的方法。

3.例题3:合并两个有序数组

例题来源(力扣) : https://leetcode.cn/problems/merge-sorted-array/description/
这道题的大概思路 : 给你两个已经排好序(非递减,即从小到大)的数组 nums1  和 nums2 ,但
nums1  后面有额外的空间(题目里说 nums1  初始长度是 m + n ,前 m  个是有效元素,后 n  个
是闲置的 0  )。你需要把 nums2  的元素合并到 nums1  里,并且合并后整个 nums1  仍然保持非
递减顺序,最后结果直接存在 nums1  里,不用返回新数组。
这道题有三种解法。下面我们一一来看。

方法一 : 先合并再冒泡排序

void merge(int* nums1, int nums1Size, int m, int* nums2, int nums2Size, int n) 
{
    // 1. 合并 nums2 到 nums1 中
    int k = 0;
    for (int i = m; i < m + n; i++) {
        nums1[i] = nums2[k];
        k++;
    }

    // 2. 冒泡排序实现升序排列
    int len = m + n;
    for (int i = 0; i < len - 1; i++) {
        for (int j = 0; j < len - i - 1; j++) {
            if (nums1[j] > nums1[j + 1]) {
                // 交换元素
                int temp = nums1[j];
                nums1[j] = nums1[j + 1];
                nums1[j + 1] = temp;
            }
        }
    }
}

思路:

- 合并阶段:利用循环,把  nums2  中的元素依次放到  nums1  中原本后面闲置的位置(即

从下标  m  开始的位置 ),完成两个数组合并为一个数组的操作。


- 排序阶段:采用冒泡排序,对合并后的  nums1  数组(长度为  m + n  )进行升序排序,通

过相邻元素比较和交换,让整个数组有序。
 
复杂度分析:
 
- 时间复杂度:合并操作是  O(n) ( n  是  nums2  的长度 ),冒泡排序的时间复杂度是

 O((m + n)²)  。总的时间复杂度由冒泡排序主导,为 O((m + n)²)  ,因为  (m + n)²  增长速度

比  n  快得多。


- 空间复杂度:整个过程只用到了几个临时变量(如  k 、 i 、 j 、 temp  等 ),没有额外开

辟大量数据存储空间,空间复杂度是  O(1)  。

如果大家不懂冒泡排序的话,小编之前在c语言的讲解中详细讲解了冒泡排序的逻辑以及代码内

容。有兴趣的可以去看一下。

方法二 : 先合并再快速排序(借助 qsort  排序法)

// 比较函数,用于 qsort,实现升序排序
int compare(const void *a, const void *b)
{
    return (*(int *)a - *(int *)b);
}
void merge(int* nums1, int nums1Size, int m, int* nums2, int nums2Size, int n) 
{
    // 1. 合并 nums2 到 nums1 中
    int k = 0;
    for (int i = m; i < m + n; i++) {
        nums1[i] = nums2[k];
        k++;
    }
    
    // 2. 使用 qsort 对合并后的 nums1 进行升序排序
    qsort(nums1, m + n, sizeof(int), compare);
}

思路:
 
- 合并阶段:和方法一一样,通过循环将  nums2  的元素放到  nums1  后面的闲置位置,完

成合并。


- 排序阶段:调用标准库函数  qsort  对合并后的  nums1  数组进行升序排序。 qsort  内部一

般采用快速排序算法(优化版本,平均情况高效 ),根据自定义的  compare  比较函数(返

回  a - b  实现升序 )来排序。
 
复杂度分析:
 
- 时间复杂度:合并操作时间复杂度是  O(n)  。 qsort  基于快速排序,平均时间复杂度是

 O((m + n)log(m + n))  ,最坏情况(比如数组完全有序等极端情况,快速排序退化为冒泡排

序逻辑,但实际  qsort  有优化,一般不考虑 )时间复杂度是  O((m + n)²)  ,这里按平均情

况,总的时间复杂度由  qsort  主导,为  O((m + n)log(m + n))  。


- 空间复杂度:合并阶段空间复杂度  O(1)  , qsort  内部实现可能会用到递归栈或者额外的

辅助空间,不过空间复杂度平均情况是  O(log(m + n)) (递归栈深度 ),最坏情况是  O(m +

n)  ,但整体相对于方法一在排序环节更高效。

qsort排序小编之前也在c语言的相关内容中讲解过,有兴趣的可以去了解一下。

方法三 : 双指针(从后往前合并)

void merge(int* nums1, int nums1Size, int m, int* nums2, int nums2Size, int n)
{
    int l1 = m - 1;  // nums1 中有效元素(即原本要合并的元素)的最后一个下标
    int l2 = n - 1;  // nums2 中元素的最后一个下标
    int l3 = m + n - 1;  // 合并后 nums1 数组的最后一个下标
    // 同时遍历 nums1 和 nums2 的有效元素(从后往前)
    while (l1 >= 0 && l2 >= 0) {
        if (nums1[l1] > nums2[l2]) {
            // nums1 当前元素更大,放到合并后数组的当前最后位置
            nums1[l3--] = nums1[l1--];
        } else {
            // nums2 当前元素更大或相等,放到合并后数组的当前最后位置
            nums1[l3--] = nums2[l2--];
        }
    }
    // 如果 nums2 还有剩余元素(说明 nums1 有效元素已经处理完了),直接放到 nums1 前面位置
    while (l2 >= 0) {
        nums1[l3--] = nums2[l2--];
    }
}

思路:
 
- 利用三个指针, l1  指向  nums1  中原有有效元素的末尾, l2  指向  nums2  元素的末

尾, l3  指向合并后  nums1  数组的末尾。


- 从后往前比较  nums1  和  nums2  中的元素,把较大的元素放到  nums1  中  l3  指向的位

置,然后对应的指针向前移动。


- 当  nums1  原有有效元素处理完( l1 < 0  ),如果  nums2  还有剩余元素( l2 >= 0  ),

直接把这些元素依次放到  nums1  中剩下的位置,因为  nums2  本身是有序的,这样就能保

证合并后整体有序。
 
复杂度分析:
 
- 时间复杂度:整个过程中, l1  从  m - 1  往前遍历到  -1  , l2  从  n - 1  往前遍历到

 -1  , l3  从  m + n - 1  往前遍历到  -1  ,总的操作次数是  m + n  次,所以时间复杂度是

 O(m + n)  。


- 空间复杂度:只用到了几个指针变量( l1 、 l2 、 l3  ),没有额外开辟大量数据存储空

间,空间复杂度是  O(1)  。
 

在实际应用中,双指针法(方法三) 是最优解,充分利用了数组有序的条件,时间和空间复杂都
更优;方法一仅适合理解排序和合并逻辑,实际很少用;方法二借助库函数简化了排序实现,但效
率不如方法三且依赖库 。
好了,以上就是关于顺序表部分有关算法题的内容。这些题都运用了一个共同的方法,那就是双指
针法。双体执法在后面的算法题中属于非常重要且非常常见的一种算术方法,大家一定要了解并且
熟练掌握它。

以后小编也会更新一些常见的算法题和一些比较重要的算法方法。喜欢的可以给小编留个关注,感
谢大家的观看。
Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐