使用动态规划实现文本对齐

30

我正在尝试通过MIT OCW 这里 上的课程来理解动态规划的概念。OCW视频上的解释非常好,但我感觉直到我将解释实现到代码中,我才真正理解了它。在实现过程中,我参考了一些讲义这里,特别是第3页。

问题是,我不知道如何将某些数学符号翻译成代码。这是我已经实现的部分(我认为它已经正确实现):

import math

paragraph = "Some long lorem ipsum text."
words = paragraph.split(" ")

# Count total length for all strings in a list of strings.
# This function will be used by the badness function below.
def total_length(str_arr):
    total = 0

    for string in str_arr:
        total = total + len(string)

    total = total + len(str_arr) # spaces
    return total

# Calculate the badness score for a word.
# str_arr is assumed be send as word[i:j] as in the notes
# we don't make i and j as argument since it will require
# global vars then.
def badness(str_arr, page_width):
    line_len = total_length(str_arr)
    if line_len > page_width:
        return float('nan') 
    else:
        return math.pow(page_width - line_len, 3)

现在我不明白的是讲义中的第3到5点。我真的不理解,也不知道从哪里开始实施这些。到目前为止,我已经尝试迭代单词列表,并计算每个可能的行尾的错误程度,就像这样:
def justifier(str_arr, page_width):
    paragraph = str_arr
    par_len = len(paragraph)
    result = [] # stores each line as list of strings
    for i in range(0, par_len):
        if i == (par_len - 1):
            result.append(paragraph)
        else:
            dag = [badness(paragraph[i:j], page_width) + justifier(paragraph[j:], page_width) for j in range(i + 1, par_len + 1)] 
            # Should I do a min(dag), get the index, and declares it as end of line?

但是,我不知道如何继续这个函数,说实话,我也不理解这一行:

dag = [badness(paragraph[i:j], page_width) + justifier(paragraph[j:], page_width) for j in range(i + 1, par_len + 1)] 

我该如何将justifier作为一个int返回(因为我已经决定将返回值存储在列表result中)。我应该再创建一个函数并从那里递归吗?是否应该有任何递归呢?

您能否告诉我接下来该怎么做,并解释这是如何使用动态规划的?我真的看不出递归在哪里,子问题又是什么。

非常感谢。


1
这个链接比你正在使用的那个更清晰一些,尽管下标有点难以辨认(很难分辨'i'和'1'):http://cs.nyu.edu/courses/fall11/CSCI-GA.1170-003/TextAlignment.pdf - AlexSilva
@AlexSilva 好的,我会先阅读并在有新想法时更新问题/答案。感谢提供链接。 - bertzzie
5个回答

23

如果你在理解动态规划的核心思想方面有困难,这是我的看法:

动态规划本质上是为了 时间复杂度 而牺牲 空间复杂度(但你使用的额外空间与你所节省的时间相比通常非常小,因此如果正确实现,动态规划完全值得)。当你进行递归调用时,你可以将每次调用的值存储下来(例如在数组或字典中),这样当你在递归树的另一个分支中遇到相同的递归调用时,就可以避免再次计算。

而且,你并不一定需要使用递归。这是我使用循环解决你正在处理的问题的实现方式。我非常仔细地遵循了由AlexSilva提供的 TextAlignment.pdf。希望对你有所帮助。

def length(wordLengths, i, j):
    return sum(wordLengths[i- 1:j]) + j - i + 1


def breakLine(text, L):
    # wl = lengths of words
    wl = [len(word) for word in text.split()]

    # n = number of words in the text
    n = len(wl)    

    # total badness of a text l1 ... li
    m = dict()
    # initialization
    m[0] = 0    

    # auxiliary array
    s = dict()

    # the actual algorithm
    for i in range(1, n + 1):
        sums = dict()
        k = i
        while (length(wl, k, i) <= L and k > 0):
            sums[(L - length(wl, k, i))**3 + m[k - 1]] = k
            k -= 1
        m[i] = min(sums)
        s[i] = sums[min(sums)]

    # actually do the splitting by working backwords
    line = 1
    while n > 1:
        print("line " + str(line) + ": " + str(s[n]) + "->" + str(n))
        n = s[n] - 1
        line += 1

17

对于仍然感兴趣的人:关键是从文本末尾开始向后移动(如此处所述)。 如果这样做,您只需比较已经记忆的元素。

假设words是要根据textwidth进行换行的字符串列表。那么,在讲座符号中,任务可以简化为三行代码:

import numpy as np

textwidth = 80

DP = [0]*(len(words)+1)

for i in range(len(words)-1,-1,-1):
    DP[i] = np.min([DP[j] + badness(words[i:j],textwidth) for j in range(i+1,len(words)+1)])

随着:

def badness(line,textwidth):

    # Number of gaps
    length_line = len(line) - 1

    for word in line:
        length_line += len(word)

    if length_line > textwidth: return float('inf')

    return ( textwidth - length_line )**3

他提到可以添加第二个列表来跟踪断点位置。您可以通过修改代码来实现:

DP = [0]*(len(words)+1)
breaks = [0]*(len(words)+1)

for i in range(len(words)-1,-1,-1):
    temp = [DP[j] + badness(words[i:j],args.textwidth) for j in range(i+1,len(words)+1)]

    index = np.argmin(temp)

    # Index plus position in upper list
    breaks[i] = index + i + 1
    DP[i] = temp[index]

要恢复文本,只需使用断点位置列表:

def reconstruct_text(words,breaks):                                                                                                                

    lines = []
    linebreaks = []

    i = 0 
    while True:

        linebreaks.append(breaks[i])
        i = breaks[i]

        if i == len(words):
            linebreaks.append(0)
            break

    for i in range( len(linebreaks) ):
        lines.append( ' '.join( words[ linebreaks[i-1] : linebreaks[i] ] ).strip() )

    return lines

Result: (text = reconstruct_text(words,breaks))

Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy
eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam
voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet
clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit
amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam
nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed
diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet
clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.

有人可能会想要添加一些空格。这很棘手(因为人们可能会提出各种美学规则),但一个天真的尝试可能是:

import re

def spacing(text,textwidth,maxspace=4):

    for i in range(len(text)):

        length_line = len(text[i])

        if length_line < textwidth:

            status_length = length_line
            whitespaces_remain = textwidth - status_length
            Nwhitespaces = text[i].count(' ')

            # If whitespaces (to add) per whitespace exeeds
            # maxspace, don't do anything.
            if whitespaces_remain/Nwhitespaces > maxspace-1:pass
            else:
                text[i] = text[i].replace(' ',' '*( 1 + int(whitespaces_remain/Nwhitespaces)) )
                status_length = len(text[i])

                # Periods have highest priority for whitespace insertion
                periods = text[i].split('.')

                # Can we add a whitespace behind each period?
                if len(periods) - 1 + status_length <= textwidth:
                    text[i] = '. '.join(periods).strip()

                status_length = len(text[i])
                whitespaces_remain = textwidth - status_length
                Nwords = len(text[i].split())
                Ngaps = Nwords - 1

                if whitespaces_remain != 0:factor = Ngaps / whitespaces_remain

                # List of whitespaces in line i
                gaps = re.findall('\s+', text[i])

                temp = text[i].split()
                for k in range(Ngaps):
                    temp[k] = ''.join([temp[k],gaps[k]])

                for j in range(whitespaces_remain):
                    if status_length >= textwidth:pass
                    else:
                        replace = temp[int(factor*j)]
                        replace = ''.join([replace, " "])
                        temp[int(factor*j)] = replace

                text[i] = ''.join(temp)

    return text

What gives you: (text = spacing(text,textwidth))

Lorem  ipsum  dolor  sit  amet, consetetur  sadipscing  elitr,  sed  diam nonumy
eirmod  tempor  invidunt  ut labore  et  dolore  magna aliquyam  erat,  sed diam
voluptua.   At  vero eos  et accusam  et justo  duo dolores  et ea  rebum.  Stet
clita  kasd  gubergren,  no  sea  takimata sanctus  est  Lorem  ipsum  dolor sit
amet.   Lorem  ipsum  dolor  sit amet,  consetetur  sadipscing  elitr,  sed diam
nonumy  eirmod  tempor invidunt  ut labore  et dolore  magna aliquyam  erat, sed
diam  voluptua.  At vero eos et accusam et  justo duo dolores et ea rebum.  Stet
clita  kasd gubergren, no sea  takimata sanctus est Lorem  ipsum dolor sit amet.

感谢您的贡献。我有一个问题。说这个算法也考虑了最后一行末尾的空格是正确的吗?通常我们想要的是在前n-1行中尽可能多地放置单词,并将额外的单词留给最后一行。就像:XXXXXXXXXXXX XXXXXXXXXXXX XXXXXXXXXXXX XX 而您的算法则优化了以下形式:XXXXXXXXXXXX XXXXXXXXXXXX XXXXXXXXXXXX XXXXXXXXXXXX您如何扩展以获得先前的配置?尝试使用DP解决所有text[:k]问题,并选择最小化的那个? - Dalmo1991
关于我之前的评论,我无法进行格式化。因此,请将“XXXXXXXXXXXX XXXXXXXXXXXX XXXXXXXXXXXX XX”中的空格视为换行符,将X视为单词。 - Dalmo1991
@Dalmo1991 你关于最后一行的说法是正确的。上面的算法没有单独处理它。据我所知,调整段落的最后一行的间距被认为是品味的问题,没有严格的规定。从理论上讲,我对所需行为的影响并不立即清楚。从实际角度来看,您可以通过将第1行中的for i in range(len(text))更改为for i in range(len(text)-1)来以低廉的价格实现此目的。附言:我注意到注释代码的价值...当我有时间时,我可能会更新上面的代码。 - Suuuehgi

1

我刚刚看了这个讲座并尽力理解了它。我已经按照提问者的类似格式放入了代码。我在这里使用了递归,就像讲座中所解释的那样。
第三点定义了递归。这基本上是一种自下而上的方法,在其中您先计算与较高输入相关的函数值,然后再用它来计算较低输入的函数值。
讲座将其解释为:
DP(i)= min(DP(j)+ badness(i,j))
对于j从i + 1到n变化。
在这里,i从n变化到0(自下而上!)。
由于DP(n)= 0,因此
DP(n-1)= DP(n)+ badness(n-1,n)
然后,您可以根据D(n-1)和D(n)计算D(n-2),并从它们中取最小值。
这样,您可以下降到i = 0,这就是badness的最终答案!
在第4点中,正如您所看到的,这里有两个循环。一个是i,另一个是i内的j。
因此,当i = 0时,j(max)= n,i = 1时,j(max)= n-1,... i = n,j(max)= 0。
因此总时间=它们的加法= n(n + 1)/ 2。
因此O(n ^ 2)。
第5点只是确定DP [0]的解决方案!
希望这可以帮助您!
import math

justification_map = {}
min_map = {}

def total_length(str_arr):
    total = 0

    for string in str_arr:
        total = total + len(string)

    total = total + len(str_arr) - 1 # spaces
    return total

def badness(str_arr, page_width):
    line_len = total_length(str_arr)
    if line_len > page_width:
        return float('nan') 
    else:
        return math.pow(page_width - line_len, 3)

def justify(i, n, words, page_width):
    if i == n:

        return 0
    ans = []
    for j in range(i+1, n+1):
        #ans.append(justify(j, n, words, page_width)+ badness(words[i:j], page_width))
        ans.append(justification_map[j]+ badness(words[i:j], page_width))
    min_map[i] = ans.index(min(ans)) + 1
    return min(ans)

def main():
    print "Enter page width"
    page_width = input()
    print "Enter text"
    paragraph = input() 
    words = paragraph.split(' ')
    n = len(words)
    #justification_map[n] = 0 
    for i in reversed(range(n+1)):
        justification_map[i] = justify(i, n, words, page_width)

    print "Minimum badness achieved: ", justification_map[0]

    key = 0
    while(key <n):
        key = key + min_map[key]
        print key

if __name__ == '__main__':
    main()

1
实际上,从n到0是自顶向下的,而从0到n是自底向上的。通常,这类问题的递归解决方案是自顶向下的,而循环解决方案则是自底向上的。 - Xgh05t

1

Java实现 给定最大行宽L,使文本T两端对齐的方法是考虑文本的所有后缀(为了精确起见,考虑单词而不是字符来形成后缀)。 动态规划就是“小心的暴力”。 如果您考虑暴力方法,则需要执行以下操作。

  1. 考虑在第一行中放置1、2、..n个单词。
  2. 对于第1种情况中描述的每种情况(假设i个单词放在第1行),考虑在第二行中放置1、2、..n-i个单词,然后在第三行上放置剩余的单词等等..

相反,让我们只考虑找出将单词放在一行开头的成本问题。 通常我们可以定义DP(i)为考虑第(i-1)个单词作为一行开头的成本。

我们如何为DP(i)形成递推关系?

如果第j个单词是下一行的开头,则当前行将包含单词[i:j)(不包括j),并且第j个单词成为下一行开头的成本将为DP(j)。因此,DP(i) = DP(j) + 将单词[i:j) 放在当前行的成本。由于我们希望最小化总成本,因此可以将DP(i)定义如下。
递归关系: DP(i) = min { DP(j) + 将单词[i:j]放入当前行的成本 } 对于所有j在[i+1, n]
注意:当j = n时,表示没有单词留在下一行。
基本情况:DP(n) = 0 => 此时没有单词需要写入。
总结:
  1. 子问题:后缀,单词[:i]
  2. 猜测:下一行从哪里开始,选择数量 n - i -> O(n)
  3. 递推公式:DP(i) = min {DP(j) + 将单词[i:j) 放入当前行的代价} 如果使用记忆化搜索,则花括号内的表达式应该在 O(1) 时间内计算,循环运行 O(n) 次(选择次数)。 i 从 n 变化到 0 => 因此总复杂度降至 O(n^2)。

尽管我们已经推导出了文本对齐的最小代价,但我们还需要通过跟踪选择上述表达式中最小的 j 值来解决原始问题,以便稍后使用它来打印对齐的文本。这个想法是保持父指针。

希望这可以帮助您理解解决方案。下面是上述思路的简单实现。

 public class TextJustify {
    class IntPair {
        //The cost or badness
        final int x;

        //The index of word at the beginning of a line
        final int y;
        IntPair(int x, int y) {this.x=x;this.y=y;}
    }
    public List<String> fullJustify(String[] words, int L) {
        IntPair[] memo = new IntPair[words.length + 1];

        //Base case
        memo[words.length] = new IntPair(0, 0);


        for(int i = words.length - 1; i >= 0; i--) {
            int score = Integer.MAX_VALUE;
            int nextLineIndex = i + 1;
            for(int j = i + 1; j <= words.length; j++) {
                int badness = calcBadness(words, i, j, L);
                if(badness < 0 || badness == Integer.MAX_VALUE) break;
                int currScore = badness + memo[j].x;
                if(currScore < 0 || currScore == Integer.MAX_VALUE) break;
                if(score > currScore) {
                    score = currScore;
                    nextLineIndex = j;
                }
            }
            memo[i] = new IntPair(score, nextLineIndex);
        }

        List<String> result = new ArrayList<>();
        int i = 0;
        while(i < words.length) {
            String line = getLine(words, i, memo[i].y);
            result.add(line);
            i = memo[i].y;
        }
        return result;
    }

    private int calcBadness(String[] words, int start, int end, int width) {
        int length = 0;
        for(int i = start; i < end; i++) {
            length += words[i].length();
            if(length > width) return Integer.MAX_VALUE;
            length++;
        }
        length--;
        int temp = width - length;
        return temp * temp;
    }


    private String getLine(String[] words, int start, int end) {
        StringBuilder sb = new StringBuilder();
        for(int i = start; i < end - 1; i++) {
            sb.append(words[i] + " ");
        }
        sb.append(words[end - 1]);

        return sb.toString();
    }
  }

0

根据您的定义,这是我的想法。

import math

class Text(object):
    def __init__(self, words, width):
        self.words = words
        self.page_width = width
        self.str_arr = words
        self.memo = {}

    def total_length(self, str):
        total = 0
        for string in str:
            total = total + len(string)
        total = total + len(str) # spaces
        return total

    def badness(self, str):
        line_len = self.total_length(str)
        if line_len > self.page_width:
            return float('nan') 
        else:
            return math.pow(self.page_width - line_len, 3)

    def dp(self):
        n = len(self.str_arr)
        self.memo[n-1] = 0

        return self.judge(0)

    def judge(self, i):
        if i in self.memo:
            return self.memo[i]

        self.memo[i] = float('inf') 
        for j in range(i+1, len(self.str_arr)):
            bad = self.judge(j) + self.badness(self.str_arr[i:j])
            if bad < self.memo[i]:
                self.memo[i] = bad

        return self.memo[i]

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接