轻松入门自然语言处理系列 02 数据结构与算法基础

450 阅读9分钟

前言

本文主要介绍了开发中经常用到的数据结构与算法,包括数据结构与算法的重要性、动态规划和DTW算法。

一、数据结构与算法的重要性

1.复杂度分析

在实际开发中,可以通过分析,得到代码中哪里存在问题、需要进行优化,从而可以提升效率。

算法复杂度衡量的是一个算法的效率,比如一个算法运行下来需要多长时间?需要消耗多少资源?根据算法复杂度,我们可以评估一个算法的优劣。在复杂度的衡量上,我们经常使用Big O表示法

算法的复杂度分为时间复杂度和空间复杂度,分别从时间和内存空间上来衡量一个算法。

复杂度的理解是极为重要的。在从事AI工作中经常会碰到各种各样程序效率低的问题,一个模型训练起来可能需要几天甚至几个月。这时候最直接的解决方式就是加机器,但也是最笨的方法。作为一名AI工程师,我们首先需要想到如何从根本上优化算法,比如检查是否使用了合理的数据结构?

例如,如果一个程序需要经常做数据的查询,那这时候你要考虑用像哈希等合理的数据结构了;相反,如果你用的是列表(list),查询速度就会变得很慢。再比如,假设我们需要寻找一堆数据中的最大的几个数,很多人可能会选择先把所有的数做排序,之后再提取最大的前几个。但有没有比这个更高效的做法呢? 实际上,在这个场景,我们可以使用一个优先队列(priority queue)来更快速地查询。

所以,每一个小的细节决定了整个程序的效率。这也是为什么一定要重视算法复杂度的原因。

2.递归函数的复杂度

斐波那契数列的递归实现如下:

def fib (n):
    if n==1 or n == 2:
        return 1
    else:
        return fib(n-2) + fib(n-1)

现在分析斐波那契数列递归实现的时间复杂度:

在这里插入图片描述

可以看到,斐波拉契数列的原始实现的时间复杂度为O(2^n),由分析可知,这是因为出现了大量的重复计算,没有重复利用已经计算过的结果导致的。解决办法可以是通过缓存保存已经计算好的结果,在需要的时候直接取出使用即可,这就是动态规划的思想。

再分析斐波那契数列递归实现的空间复杂度:

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

递归时会发生上下文切换(Context Switch),暂停当前的任务去完成其他任务,所以需要保存当前任务的状态,这样可以保证在完成其他任务回到当前任务时,可以在之前的状态开始继续完成当前的任务,一般可以由操作系统来自动完成,具体是使用栈来完成的。

在递归调用时,会不断地入栈和出栈,所占用的栈空间与问题规模呈线性关系,即空间复杂度为O(N)。

二、动态规划算法

1.动态规划与自然语言处理

之所以斐波那契数列的递归实现的复杂度很高,归其原因就是进行了大量的重复计算。所以解决此问题的关键点在于如何不做重复计算。想做到这一点,最直接的方法就是已经计算好的结果先存下来,之后遇到计算需求时先看看有没有已经算好,如果已经算好了就直接使用,如果没有则进行计算。这就是动态规划的核心思想。

斐波拉契数列的循环实现如下:

def fib(n):
    a, b = 1, 1
    c = 0
    for i in range(2, n):
        c = a + b
        a = b
        b = c
    return c

从上述代码中可以看出,只维护了当前的前两个结果,因为当前的问题只依赖于前两个,所以没有必要把所有之前的结果存下来。这样一来,在这个实现中,我们所需要的空间复杂度就是O(1);从时间复杂度来讲,因为要循环n次,所以很明显是O(n)的复杂度。 这种实现相比递归要高效很多。

斐波拉契数列的循环实现比递归实现更加高效,因为这种方式中可以重复利用已经计算好的值:

在这里插入图片描述

此时时间复杂度更低,为O(N),显然远远低于递归方式指数级的时间复杂度。这里创建了一维的辅助数组,就是用了动态规划的算法。

多项式复杂度和指数级复杂度:

对于算法复杂度来说,通常分类两大类,分为是多项式复杂度指数级复杂度。多项式复杂度指的是类似于O(n^p)的复杂度,它包括了像O(1), O(n), O(n^2), O(n logn)这类复杂度。指数级复杂度则可用O(p^n)来表示,斐波那契数列的递归时间就是属于指数级复杂度O(2^n)。

可以看一下它们之间的区别是什么。对于指数级复杂度来讲,算法效率会随着问题的大小指数级增长,这其实是不可取的。可以想象一下,假如n=10或者n=100, 复杂度p^10和p^100之间差别有多大。一般来讲,指数级复杂度算法只能适用在很小的问题上,一旦问题变大就没有办法使用了。

从复杂度理论的角度来讲,如果一个算法的复杂度为多项式复杂度,我们可以理解为这个问题是比较简单的问题;相反,如果为指数级复杂度,我们则认为是一个非常难的问题。所以,一旦在实际工作中提出的算法为指数级复杂度,那我们有必要想一想要不要采用了。

动态规划算法在自然语言处理领域是最常用的一个算法,NLP的很多模型都用到了动态规划的思想,编辑距离、维特比算法就用到了动态规划算法,其中Viterbi算法(维特比算法)是自然语言处理领域非常经典的技术,在序列模型解码(Decoding)阶段经常会用到,如HMM、CRF中的解码,Viterbi算法本身就是动态规划。

2.最大递增子串

最大递增子串问题(Longest increasing subsequence problem):

给定一组序列A(1),A(2),...A(n),找出一个最长子序列(不要求是连续的),使得序列里的数是递增的。 举例: 比如给定一个序列10,9,2,5,3,7,101,18,最长的子序列为2,3,7,18,所以长度为4。

最大递增子串问题在动态规划领域非常常见,也是一个经典的问题。可以借助于它的思想来解决很多其他类似的问题。基于上述问题的描述,我们应该如何设计一个动态规划算法呢?实际上,在具体设计高效的算法之前,有必要想一下最笨的方法比如穷举法。然后再从穷举法逐步优化成最高效的算法形态。这种解题思路过程非常重要。在面试过程中,面试官都想看到你是如何从一个很“笨”的方法逐步改进为高效的方法。这个思维过程可以体现一个人解决问题的能力。

面试技巧:即便能够一下子回答出面试官的答案,但尽量循序渐进,从笨的方法开始讲到最优的方法,把整个的思路呈现给面试官,这样的效果要好于一下子给出最优的答案。

最大递增子串的实现可参考www.cnblogs.com/zawjdbb/p/7…

给定一个组序列A(1),A(2),... A(n),找出一个连续子序列A(i)...A(j),使得它们之和在所有连续子序列中最大。

最暴力的方法就是寻找序列的所有连续子集,即Power Set(幂集),然后计算所有的数,显然复杂度为O(2^N),即指数级。

第二种方式使用动态规划。

为了解决一个大的问题,从小问题开始解决,一旦解决了小问题,就把这些问题的答案存放在内存空间为后续提供使用。所以对于动态规划算法有几个关键点:

  • 如何把一个问题拆解成更小的子问题?并把大问题以子问题的形式表示出来?
  • 如何存放中间过程的结果?

在这里插入图片描述

代码实现如下:

# -*- coding: utf-8 -*-

'''
@Author   :   Corley
@Time     :   2022-02-19 18:16
@Project  :   NLPDevilCamp-largest_subsequence_sum
'''

import sys


def max_subseq_sum(arr):
    max_so_far = -sys.maxsize
    max_current = 0
    for i in range(0, len(arr)):
        if max_current + arr[i] >= arr[i]:
            max_current = max_current + arr[i]
        else:
            max_current = arr[i]
        if max_so_far < max_current:
            max_so_far = max_current
    return max_so_far


print(max_subseq_sum([-2, -3, 4, -1, -2, 1, 5, -3]))
print(max_subseq_sum([-1, 1, 2, 3, 4, -5, 2, 4]))

输出:

7
11

3.换硬币问题

给定一组不同面值的硬币(每种硬币假定无穷多),它们的面值分别为v(1)<v(2),...<v(n),并且均为整数。假如v(1)=1,给定任意整数C,我们希望用上述的硬币来凑够面值C,同时要求硬币的数量的最少的。如何设计算法? 比如给定三种面值的硬币1元、2元、3元,现在需要换取5元,那最优的方案是使用2枚硬币(2+3=5)。

在这里插入图片描述

代码实现如下:

# -*- coding: utf-8 -*-

'''
@Author   :   Corley
@Time     :   2022-02-19 18:35
@Project  :   NLPDevilCamp-coin_change
'''

import sys


def min_coins(coins, C):
    # coins: 硬币的面值
    # C: 需要换的纸币面值

    # table[i] 存储换取面值为i的纸币,需要用到的最少量的硬币数
    table = [0 for _ in range(C + 1)]

    # Base case
    table[0] = 0
    # 初始化
    for i in range(1, C + 1):
        table[i] = sys.maxsize

    # 对于每一种价值i来计算,最少用多少硬币可以换取?
    for i in range(1, C + 1):
        # Go through all coins smaller than i
        for j in range(len(coins)):
            if coins[j] <= i:
                sub_res = table[i - coins[j]]
                if sub_res != sys.maxsize and sub_res + 1 < table[i]:
                    table[i] = sub_res + 1
    return table[C]


if __name__ == '__main__':
    arr = [1, 2, 3]
    n = 5
    print(min_coins(arr, n))

输出:

2

三、DTW算法和应用

1.DTW(Dynamic Time Warping)算法介绍

机器学习中,数据分为静态数据和动态数据。动态数据最典型的例子就是时间序列数据,只要数据沿着时间的维度变化,就是时间序列数据。时间序列数据(Time Series Data)在生活中到处可见,比如人类的语音、股价的波动、每日的气温、语音、甚至文本都可以归类于时间序列数据。这些数据一个共同点是都是随着时间的推移而变化的。在很多AI的任务中,我们或多或少都需要处理一些跟时间序列相关的问题。比如在推荐系统中,要预测一个人未来可能会喜欢什么。

时间序列的分析相比静态数据的分析有更多挑战,毕竟数据是“运动”而不是“静态不变”的。对于时间序列数据,我们经常使用HMM、CRF、RNN、LSTM等方法来做分析和预测。但实际上,还有一个很有效的方法就是DTW,全称为dynamic time warping。这个方法的核心点在于可以用来计算两个时间序列的相似度。

DTW是序列分析领域的一个很重要的算法,其核心也是动态规划。

两种情况如下:

在这里插入图片描述

左边这种情况,两个序列的点是对应的,计算相似度时,对应位置计算即可;

右边这种情况,两个序列的点不是对应的,就不能再像左边那样直接计算距离,此时就可以用到DTW算法。

DTW算法适合计算两个不规则序列之间的相似度和距离。

如果我们能算出两个序列的相似度,可以实现很多有趣的应用。比如我们对着手机哼了一首歌,那输入信号就是语音时间序列,然后手机软件通过检索系统找出跟这个旋律匹配的歌曲,并返回歌名给到我们。 当然,还有很多有趣的应用是可以搭建在相似度基础上的。 总而言之,DTW算法是用来计算两个时间序列的相似度,也是所有算法中最为简单和便捷的算法。

2.DTW的应用场景

DTW的应用场景很多:

  • 声音识别 在这里插入图片描述

    DTW用于计算两个声音序列之间的距离。

  • 股票相似K线 在这里插入图片描述

    基于历史会重演的观点,以前出现过的K线趋势还可能会再次出现,从而实现未来趋势的预测。

3.DTW算法实现

计算两个序列之间的距离:

  1. 确定点之间的对应关系 DTW在训练的过程中学习,得到最好的点之间的对应关系。
  2. 计算距离

计算两个时间序列的相似度时,最大的挑战在于两个时间序列的长度很可能是不一样的。如果我们知道两个时间序列之间的对应关系,这个问题也变得非常简单,但问题在于对应关系也是不知道的。此时可以把问题转换成另外一种表达形式,进而得到解答思路。

两种对应关系的情况如下:

在这里插入图片描述

其中,左边是比较理想的情况,是按照顺序的,没有出现交叉;右边则出现了交叉,要尽量保证不出现这种情况。

同时会有多个对应关系,两个序列的对应关系确定了距离,不同的对应关系会有不同的距离,DTW算法的目标是寻找距离最小的对应关系。因为对应关系的数量会随着问题规模的扩大而增多,此时就需要动态规划算法来提升效率。

在这里插入图片描述

可以看到,在保证对应关系没有交叉、两个序列的起始点和终点分别对应的情况下,寻找距离最短的对应关系的问题就转化为了在二维空间中寻找起始点(1, 1)到终点的最佳(成本最小)路径。此时就用到了动态规划。

计算方式如下:

在这里插入图片描述

从上面可以得到,在DTW的实现中,需要考虑几点:

  • 起始点与终止点:一般起始点采用(0,0), 终止点采用(len(s), len(t))
  • Local continuity:在局部上做alignment的时候可以稍微灵活一些,比如跳过1个value等。
  • Global Continuity:从全局的角度设定的限制条件。
  • 另外就是每一个路径的权重。

代码实现如下:

# -*- coding: utf-8 -*-

'''
@Author   :   Corley
@Time     :   2022-02-19 19:59
@Project  :   NLPDevilCamp-dtw
'''

import numpy as np

import sys


# 定义距离
def euc_dist(v1, v2):
    return np.abs(v1 - v2)


# DTW的核心过程,实现动态规划
def dtw(s, t, mww=10):
    '''
    :param s: source sequence
    :param t: target sequence
    :param mww: max warping window, int, optional (default = infinity)
    :return: dtw distance
    '''
    m, n = len(s), len(t)
    dtw = np.zeros((m, n))
    dtw.fill(sys.maxsize)
    # 初始化过程
    dtw[0, 0] = euc_dist(s[0], t[0])
    for i in range(1, m):
        dtw[i, 0] = dtw[i - 1, 0] + euc_dist(s[i], t[0])
    for i in range(1, n):
        dtw[0, i] = dtw[0, i - 1] + euc_dist(s[0], t[i])

    # 核心动态规划流程,此动态规划的过程依赖于二维图
    for i in range(1, m):
        for j in range(max(1, i - mww), min(n, i + mww)):
            cost = euc_dist(s[i], t[j])
            ds = []
            ds.append(cost + dtw[i - 1, j])
            ds.append(cost + dtw[i, j - 1])
            ds.append(cost + dtw[i - 1, j - 1])
            ds.append(cost + dtw[i - 1, j - 2] if j > 1 else sys.maxsize)
            ds.append(cost + dtw[i - 2, j - 1] if i > 1 else sys.maxsize)
            dtw[i, j] = min(ds)
    return dtw[m - 1, n - 1]


if __name__ == '__main__':
    print(dtw([1, 8, 20, 56, 11, 32, 43], [1, 33, 6, 29, 16, 9, 31, 42, 23]))


输出:

29.0

实现的更多细节可参考blog.csdn.net/weixin_3072…www.jianshu.com/p/ad9fb4b48…

总结

IT开发中,不论是前后端,还是大数据、AI等方向,数据结构与算法都是极为重要的,因为在当下高并发等情况很多,对系统的性能要求很高,就需要通过优秀的算法来保证,因此算法显得格外重要。

本文原文转载自问我社区,原文链接www.wenwoha.com/19/course_a…