iOS老司机的接地气算法Tips

3,857 阅读11分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第5天,点击查看活动详情

1. 前言

  • 在iOS行业算法除了面试时的筛选作用, 还有其他作用吗?
  • 不少一线iOS开发人员可能都会存在这个疑问. 存在即合理, 因为常见的实际业务中, 算法用到的确实不多.
  • 然而, 算法真的没用吗? 非也, 程序 = 算法 + 数据结构
  • 算法和数据结构的运用体现在iOS底层设计的方方面面.
  • 下面就抛砖引玉, 跟大家一起聊聊一些关于算法的浅见, 阅读中如发现错误, 还望评论区指正:)

2. iOS开发中的算法相关基操Tips

2.1 我们一般从以下几个维度来评估算法的优劣

  • 正确性、可读性、健壮性

  • 时间复杂度、空间复杂度

  • 算法优化的方向, 用尽量少的存储空间, 用尽量少的执行步骤.

  • 对于不能兼顾的时候, 会采用空间换时间, 或者时间换空间的方式来完成. 

2.1.1 时间复杂度

  • 大O表示法中, 时间复杂度的公式是: T(n) = O(f(n)), 其中f(n)表示每个代码执行次数之和, 而O表示正比例关系, 这个公式的全称是:算法的渐进时间复杂度.
  • 常见的时间复杂度量级从上至下越来越大:
    • 常数阶O(1)
    • 对数阶O(logN)
    • 线性对数阶O(nlogN)
    • 平方阶O(n²)
    • 立方阶O(n³)
    • K次方阶O(n^k)
    • 指数阶O(2^n)

2.1.2 空间复杂度

  • 空间复杂度是对一个算法在运行过程中临时占用存储空间大小的一个量度, 同样反映的是一个趋势, 我们用S(n)来定义.
  • 空间复杂度比较常用的有: O(1)、O(n)、O(n²).
  • 如果算法执行所需要的临时空间不随着某个变量n的大小而变化, 即此算法空间复杂度为一个常量, 可表示为O(1)
// 代码中的i、j、m所分配的空间都不随着处理数据量变化, 因此它的空间复杂度S(n) = O(1)
int j = 1;
int j = 2;
++i;
j++;
int m = i + j;
  • 空间复杂度O(n)
int[] m = new int[n];
for (i = 1; i <= n; ++i) 
{
    j = i;
    j++
}
  • 这段代码中, 第一行new了一个数组出来, 这个数据占用的大小为n, 这段代码的2-6航, 虽然有循环, 但没有再分配新的空间, 因此, 这段代码的空间复杂度主要看第一行即可, 即S(n) = O(n);

2.2 常见的数据结构及算法思想

  • 数据结构是计算机存储、组织数据的方式. 有线性结构, 树形结构, 图形结构
  • 常见的线性表有:数组, 链表, 栈, 队列, 哈希表

2.2.1 数组是一种顺序存储的线性表, 所有元素的内存地址是连续的. 

  • 但是很多语言数组是无法动态修改容量的, 例如我们NSArray. 所以需要动态扩容. 

  • 数组的优点是查找的时间复杂度为O(1), 添加和删除数据的时间复杂度为O(n). 

  • 数组的缺点是在扩容的时候需要频繁操作数据, 而且会导致占用过大的内存, 存在内存浪费. 

  • 为了解决它的缺点, 就有了链表.

2.2.2 链表是一种链式存储的线性表. 所有元素的内存地址是不连续的. 

  • 它的优点是, 不造成内存空间的浪费. 

  • 但是它的查找效率低O(n)

  • 链表的翻转, 使用头插法. 

1. 创建一个newHead指针指向null,  即ListNode newHead = null. 

2. while循环让head往后走, 直到head指向null停止

3. 创建一个tmp来记录当前head的下一个节点,  ListNode tmp = head.next

4. 让head的next指向newhead,  即 head.next = newHead

5. newhead指向当前的head, 即newhead = head

6. 将tmp赋值给head, 即head = tmp. 

7. 进入下次循环即可

2.2.2.1 如何判断链表是否有环?

  • 是通过快慢指针来实现.

  • 快指针每次前进两步, 慢指针每次前进1步. 这样如果快慢指针能相遇, 就表示有环. 

2.2.2.2 双向链表是含有前后指针的. 两者对比

  • 动态数组:开辟销毁内存空间的次数相对较少, 但可能造成内存空间的浪费

  • 双向链表, 开辟和销毁内存空间的次数相对较多, 但不会造成内存浪费

  • 如果频繁在尾部进行添加, 删除操作, 那么动态数组, 双向链表均可选择. 

  • 如果频繁在头部进行添加, 删除操作, 可以使用双向链表, 因为动态数组要将数据全部移动

  • 如果在任意位置添加删除, 可以选择双向链表

  • 如果有频繁的查询, 使用动态数组. 

2.2.2.3 如何通过数组来模拟链表?

  • 静态链表就是在链表的节点中存放值和下一个元素的索引. 

  • 删除排序链表的重复元素, 或者删除链表的重复元素, 重复元素不保留, 都是采用双指针的方法. 

2.2.2.4 栈是一种特殊的线性表, 只能在一端进行操作. 遵循LIFO, 先进后出

  • 栈的实现可以采用动态数组和链表. 

  • 可以用来解决匹配问题. 

2.2.2.5 队列是一种特殊的线性表, 只能在头尾两端进行操作. 

  • 队尾入队, 对头出队. 遵循FIFO原则. 

  • 因为队列是在头尾操作, 建议采用双向链表实现. 

  • 如何用栈实现队列:准备两个栈, instack和outstack. 入队时push到instack

  • 出队时, 如果outstack不为空, outstack弹出栈顶元素, 如果outstack为空, 就将instack所有元素逐一弹出, push到outstack中, outstack弹出栈顶元素. 

  • ps: 双端队列是能在头尾两端进行添加和删除的队列. 

2.2.3 树形结构

  • 节点的度:子树的个数

  • 树的度:所有节点度中的最大值

  • 叶子节点:度为0的节点

  • 非叶子节点:度不为0的节点

  • 层数:根节点在第一次, 根节点的子节点在第二层

  • 节点的深度:从根节点到当前节点的唯一路径上的节点总数

  • 节点的高度:从当前节点到最远叶子节点的路径上的节点总数

  • 树的深度:所有节点高度中的最大值

  • 二叉树:每个节点的度最大为2

  • 完全二叉树是指对树的节点从上到下编号都会与满二叉树对应. 

  • 它的性质是度为1的节点只有左子树. 度为1的节点要么是1个要么是0个. 

2.2.3.1 二叉树的遍历

  • 前, 中, 后序遍历, 采用栈来实现. 

  • 层序遍历采用队列来实现

2.2.3.2 二叉搜索树

  • 任意一个节点的值都大于其左子树所有节点的值, 任意一个节点的值都小于其右子树所有节点的值. 

2.2.3.3 删除节点:

  • 对于叶子节点:直接删除

  • 对于度为1的节点:用子节点替代原来节点的位置

  • 对于度为2的节点:先用前驱结点或者后继节点的值覆盖原节点的值

  • 这里前驱结点是指中序遍历中节点的前一个节点. 

  • 后继节点是中序遍历中后一个节点的值. 

2.2.3.4 因为对树的节点删除后

  • 可能会成为一个像链表的形式, 这样就达不到让添加, 删除, 搜索的复杂度维持在O(logn), 所以就有了平衡二叉树. 

  • 平衡二叉树是要求树的左右子树高度相差不超过1.所以就有了AVL树, 红黑树

  • 平衡因子:某结点的左右子树的高度差不超过1.

  • B树是一种平衡的多路搜索树. 

2.2.3.5 红黑树

  • 节点是red或者black

  • 根节点是black

  • 叶子节点都是black

  • red节点的子节点都是black

  • 从任一节点到叶子节点的所有路径都包含相同数量的black节点. 

2.2.4 哈希表

  • 哈希表是空间换时间的典型. 它的底层是使用的数组, 

2.2.4.1 哈希冲突的解决方法有哪些

  • 开放地址法:就是按照一定的规则向其他地址探测, 直到遇到空桶

  • 还可以再hash, 还有就是链地址法, 通过链表连接起来

  • jdk1.8中的哈希表是采用链表+红黑树解决哈希冲突的. 

  • 哈希函数是用来计算索引值的, 首先将key经过哈希函数生成一个hash值

  • 然后将hash值跟数组大小进行相关运算, 生成一个索引值

  • 所以要求key必须是可hash的, 而且是可比较的. 

2.2.5 堆

  • 堆的一个重要性质, 任意节点的值总是大于等于 或 小于等于 子节点的值

2.2.5.1 堆的添加

  • 堆底添加元素, 然后开始上滤, 就是和它的父节点进行比较, 如果比父节点大, 就交换位置, 然后重复这个过程, 直到根节点. 

2.2.5.2 堆的删除

  • 删除堆顶元素, 先用最后一个节点覆盖根节点, 然后删除最后一个节点, 然后执行下滤操作, 直到没有子节点了. 

2.2.5.3 批量建堆

  • 自上而下的上滤. 就是利用层序遍历的结果来进行上滤操作. 因为层序遍历就是数组的正序遍历. (这里从第二层开始即可, 第一层是根节点)

  • 自下而上的下滤. 这里是第一个非叶子节点开始. 

  • ps: 优先级队列通过二叉堆来实现. 

2.2.6 各种排序

2.2.6.1 快排

  • 原理, 逐渐的将每一个元素变成轴点元素
  • 用轴点元素分开序列, 左边都是小于等于轴点元素, 右边大于
  • 这样就可以把原来的序列分开成两个序列
  • 递归同样的操作, 直到每个元素都变成轴点元素

2.2.6.2 堆排序

  • 首先是原地建堆, 然后交换堆顶元素与尾元素. 然后将堆的数量-1, 再重复上面的操作, 

  • 对序列进行原地建堆, 重复执行下沉操作, nlogK, K远小于N

2.2.6.3 归并排序

  • 归并排序就是先拆后组合.

  • 不断地将当前的序列分割成两个子序列, 直到不能分割

  • 不断地将子序列合并成大的有序序列, 最终完成排序.

2.2.6.4 希尔排序

  • 希尔排序就是把序列看作是一个矩阵, 分成m列, 逐列进行排序. 

2.2.6.5 计数排序

  • 计数排序, 桶排序, 基数排序都不是基于比较的排序. 

2.2.7 递归

  • 递归的基本思想是拆解问题.

2.2.8 回溯

  • 什么是回溯
  • 八皇后游戏
    • 8 * 8的方格
    • 在每一个格子里放一个皇后
    • 皇后同一行同一列同一个斜线不能出现两个
    • 攻击范围内不能有第二个皇后
    • 有多少种排法
  • 九皇后, 回溯思想
    • 走迷宫, 当我们遇到N条选择, 一直往下走,
    • 走不通回退一步, 选择其他的走
    • 遍历了所有的可能
    • 选择一条路出发, 能进则进, 不能进就退一步
    • 尝试另外一条路

2.2.9 分治

  • 什么是分治?
    • 将原问题分解成若干个子问题
    • 保持子问题结构和原问题是一致的, 只是规模上不一致
    • 子问题又能不断地被分解成若干个子问题
    • 直到子问题是可以轻易地得出结果,
    • 结构是一样的, 得到原问题的解
  • 例如12个数字, 找最大值
    • 首先把12个数字分成3组, 4个一组
    • 三组里的最大数
    • 4个数字, 两两一组,
    • 得到第一组中最大的
    • 子问题找到父问题的答案
    • 三个中最大的就找到了

3. iOS中的数据结构算法应用🌰

3.1 自动释放池AutoReleasePool

  • 自动释放池AutoReleasePool就是基于双向链表这两种数据结构实现的.
  • 从数据结构的角度来看AutoReleasePool是以栈为结点通过双向链表的形式组合而成.

3.2 UINavigationController的push和pop

  • iOS中最常见的推入和弹出页面, 就是应用就是了结构.

3.3 iOS图片缓存框架SDWebImage中的数据结构和算法

  • SDWebImage的内存设计中,
  • 淘汰策略使用了队列的数据结构, 以队列先进先出的方式做淘汰
  • 采用了LRU算法, 如30分钟内是否使用过, 做图片缓存淘汰策略

发文不易, 喜欢点赞的人更有好运气👍 :), 定期更新+关注不迷路~

ps:欢迎加入笔者18年建立的研究iOS审核及前沿技术的三千人扣群:662339934,坑位有限,备注“掘金网友”可被群管通过~