Рубрики
Без рубрики

Сравнение алгоритма автозаполнения в Python, Go, Haskell

Поскольку пост сравнения веб-скребка был очень любим, вот еще одно сравнение. Задача – я … Tagged с Python, Go, Haskell, Performance.

С тех пор Сравнение веб -скребка Пост был так любим, вот еще одно сравнение. Задача состоит в том, чтобы реализовать алгоритм автозаполнения из Рыба раковина За исключением складывания дела:

  • Если то, что вы напечатали, соответствует началу любых имен файлов, завершено к тому или самому большому префиксу, общему для всех из них. Если нет ничего, что начинается с того, что вы набрали, перейдите к следующему правилу.

  • Если то, что вы напечатали, соответствует смежной части ровного имени файла, даже если оно не в начале, завершено до этого. Если нет совпадений, перейдите к следующему правилу.

  • Если именно одно имя файла содержит символы, которые вы набрали по порядку, завершите это.

(Как вы, наверное, догадались, если вы следите за мной, я был вдохновлен сделать это после моего поста на глубине-первая и ширина-первая автозаполнение .)

Я сгенерировал миллион тестов, используя сценарий Python, который я опубликую в конце. Он имеет функции для генерации набора с конкретными решениями (например, набор, в котором возможно частичное завершение из префикса, набор, в котором ничего не может быть завершено, набор, в котором можно завершить из смежного непревзойденного), поэтому гарантированно, что все Кодовые пути будут протестированы в несколько реалистичных количествах.

Вот моя реализация Python.

import json, datetime

def autocomplete(typed, possibilities):
    """The basic breadth-first algorithm: add the biggest prefix common to all continuations."""
    # Exclude things that don't match the typed prefix.
    matches = [p for p in possibilities if p.startswith(typed)]
    # str.startswith is slower than slicing, but more idiomatic, so it's a fairer comparison.
    # If there's nothing that starts with the given prefix, proceed to the next rule.
    if not matches: return autocomplete_contiguous(typed, possibilities)
    # Scan the common prefix 1 char at a time.
    i = len(typed)
    while True:
        char = None
        for s in matches:
            if len(s) <= i or char and s[i] != char:
                return s[:i]
            if not char:
                char = s[i]
        i += 1

def autocomplete_contiguous(typed, possibilities):
    """Fallen back to if nothing starts with typed. Checks for things that contain it."""
    matches = [p for p in possibilities if typed in p]
    if len(matches) == 1: return matches[0]
    if not matches: return autocomplete_in_order(typed, possibilities)
    return typed

def autocomplete_in_order(typed, possibilities):
    """Fallen back to if nothing contains the typed characters contiguously. Checks if anything has them in order."""
    matches = []
    for s in possibilities:
        lastmatch = 0
        for char in typed:
            index = s.find(char, lastmatch)
            if index < lastmatch: break
            lastmatch = index + 1
        else:
            # It's ambiguous.
            if matches: return typed
            matches.append(s)
    # There's less than 2 matches.
    if matches: return matches[0]
    return typed


if __name__ == '__main__':

    with open('data') as f:
        sets = json.load(f)

    start = datetime.datetime.now()
    for s in sets:
        autocomplete(s['typed'], s['possibilities'])
    end = datetime.datetime.now()

    print(end - start)

Обратите внимание, что я не запускаю часы, пока JSON не будет проанализирован, потому что я сравниваю алгоритм автозаполнения.

Если я сбрасываю сброс основного блока и связанного с ним импорта, чтобы мы измеряем только реализацию автозаполнения, Python выходит на 30 строк. (Я использую cloc для подсчета строк кода, поэтому комментарии/Docstrings и пустые строки исключены.) Довольно впечатляет такой сложный алгоритм.

Запустив Python 3.7, потребовалось около 5,4 секунды, чтобы запустить все миллион случаев, и для этого использовалось 1,7 ГБ памяти. Честно говоря, я ожидал хуже.

Реализация GO (без параллелизма):

package main

import (
    "encoding/json"
    "fmt"
    "io/ioutil"
    "strings"
    "time"
)

type Case struct {
    Typed         string
    Possibilities []string
}

func main() {
    var rawData, err = ioutil.ReadFile("data")
    if err != nil {
        panic(err)
    }
    var data = make([]Case, 0, 1000000)
    err = json.Unmarshal(rawData, &data)
    if err != nil {
        panic(err)
    }
    var before = time.Now()
    for i := range data {
        autocomplete(data[i].Typed, data[i].Possibilities)
    }
    var after = time.Now()
    fmt.Printf("Took %f\n", float64(after.Sub(before))/float64(time.Second))
}

func autocomplete(typed string, possibilities []string) string {
    var matches []string
    // Exclude things that don't match the typed prefix.
    for _, s := range possibilities {
        if strings.HasPrefix(s, typed) {
            matches = append(matches, s)
        }
    }
    if len(matches) == 0 {
        return autocompleteContiguous(typed, possibilities)
    }
    // Scan the common prefix 1 char at a time.
    for i := len(typed); true; i++ {
        var char byte = '~' // A dumb hack: this char doesn't appear in the dataset.
        for _, s := range matches {
            if len(s) <= i || char != '~' && s[i] != char {
                return s[:i]
            }
            if char == '~' {
                char = s[i]
            }
        }
    }
    // Go can't tell this is unreachable
    panic("Shouldn't happen")
}

func autocompleteContiguous(typed string, possibilities []string) string {
    var matches []string
    // Exclude things that don't match the typed prefix.
    for _, s := range possibilities {
        if strings.Contains(s, typed) {
            matches = append(matches, s)
        }
    }
    if len(matches) == 1 {
        return matches[0]
    } else if len(matches) == 0 {
        return autocompleteInOrder(typed, possibilities)
    }
    return typed
}

func autocompleteInOrder(typed string, possibilities []string) string {
    var matches []string
    // Exclude things that don't match the typed prefix.
    for _, s := range possibilities {
        var lastmatch = 0
        for i, char := range typed {
            var index = strings.IndexRune(s[lastmatch:], char)
            if index == -1 {
                break
            }
            // The index will be offset by our slicing, so account for that.
            lastmatch += index + 1
            // If we just found the last character, record this as a match.
            if i+1 == len(typed) {
                // There's already a match. It's ambiguous.
                if len(matches) > 0 {
                    return typed
                }
                matches = append(matches, s)
            }
        }
    }
    // If we get here, there's less than 2 matches.
    if len(matches) == 1 {
        return matches[0]
    }
    return typed
}

64 строки кода после удаления основной функции и определения структуры случая. (Я на самом деле собирался хранить случаи в файле данных в виде массивов JSON [Typed, возможностей], но у Go нет типов кортежей, поэтому он не мог легко проанализировать список неравномерных элементов, поэтому я. Отформатировал их как объекты JSON).

Конечно, если я не считаю строки «кода», которые состоят из только Заключительная скобка или скобка, количество линий Go падает до 44. Я амбивалентно, справедливо ли они считать их, поскольку они не представляют какую -либо дополнительную семантическую сложность, но занимайте вертикальное пространство, уменьшая количество кода, которое я могу установить на экране одновременно.

Время выполнения, однако, является звездным 0,5 секунды. Было использовано около 1,4 ГБ памяти, но только 700 МБ жили в физической памяти (я просматриваю использование в топ ). Я побежал Go 1.14.4.

Реализация Haskell – самая сложная измерение производительности из -за ленивой оценки. Поскольку я ничего не делаю с результатами, мне пришлось прочитать о способах заставить Хаскелла оценить, и даже пришлось Пост На R/Haskell, когда я не мог понять, как убедиться, что JSON был действительно проанализирован, прежде чем начать часы. Я думаю, что эта реализация является справедливым судом.

{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE OverloadedStrings #-}

import Data.Time
import Data.Text (Text)
import qualified Data.Text as Text
import Data.Aeson
import Data.Maybe
import Control.Exception
import Control.DeepSeq
import GHC.Generics

data Case = Case
  { typed :: !Text
  , possibilities :: ![Text]
  } deriving (Generic, Show, Read, Eq)

instance FromJSON Case
instance NFData Case

-- | The simple algorithm.
autocomplete :: Case -> Text
autocomplete (Case typed possibilities) =
  let suffixes = mapMaybe (Text.stripPrefix typed) possibilities
  in if not $ null suffixes
    then typed <> commonPrefix suffixes
    else autocompleteContiguous typed possibilities

commonPrefix :: [Text] -> Text
commonPrefix [] = ""
commonPrefix [x] = x
commonPrefix (x:y:xs) =
  case Text.commonPrefixes x y of
    Nothing -> ""
    Just (p,_,_) -> commonPrefix (p:xs)

autocompleteContiguous typed possibilities =
    let matches = filter (Text.isInfixOf typed) possibilities
    in if length matches > 1
        then typed
        else if length matches == 1
            then head matches
            else autocompleteSparse typed possibilities

autocompleteSparse typed possibilities =
    let matches = filter (isMatch typed) possibilities
    in if length matches == 1
        then head matches
        else typed
    where
        isMatch "" str = True
        isMatch substr str =
            case Text.findIndex (== Text.head substr) str of
                Nothing -> False
                Just index -> isMatch (Text.tail substr) (Text.drop (index + 1) str)

main = do
    cases <- (fromJust <$> decodeFileStrict' "data") :: IO [Case]
    mapM_ (evaluate . force) cases
    start <- getCurrentTime
    mapM_ (evaluate . autocomplete) cases
    end <- getCurrentTime
    putStrLn $ "Took " ++ (show $ diffUTCTime end start)

После удаления Главный , Случай Производство и связанные с ними импорт, а также подписи типа (я не думаю, что здесь действительно необходимы), это 33 строки.

На самом деле я не придумал эту реализацию автозаполнение и CommonPrefix ; R/Haskell Polks помогли мне. (Черт возьми, я собирался использовать строка вместо Текст Анкет Это было бы ужасно.)

Время выполнения без оптимизации составляет 2,25 секунды; Если я компилируюсь с -О2 , это падает до 1.2. Неплохо, но я все еще немного разочарован, что это так далеко позади.

И использование памяти! По какой -то причине он выделяет 3 ГБ. Флаг оптимизации не влияет на это. Я не удивлюсь, если это то, что я делаю не так, но Шиш. Я надеялся, что даже наивная реализация не займет почти вдвое больше памяти, чем Динамически напечатано Python One.

Обновление: я попробовал +Rts -s Флаги, предложенные кем -то на R/Haskell, и заявили, что только 1,5 ГБ памяти использовались. Но это не может быть правдой, не так ли? верхняя показал увеличение активной памяти на 3 ГБ. Странный.

Вот как это прошло. Не стесняйтесь критиковать мои решения в комментариях. Наконец, вот сценарий, который я использовал для создания тестовых случаев:

import string, json, time, random

allowed_chars = string.ascii_lowercase + string.digits

def gendata():
    start = time.time()
    funcs = (
        mkset_full_prefix_completion,
        mkset_partial_prefix_completion,
        mkset_typed_no_completion,
        mkset_prefix_ambiguous,
        mkset_contain_ambiguous,
        mkset_contain_completion,
        mkset_sparse_ambiguous,
        mkset_sparse_completion,
    )
    sets = [random.choice(funcs)() for i in range(1000000)]
    end = time.time()
    print(f"generated data in {end - start}")
    with open('data', 'w') as f:
        json.dump(sets, f)

def mkset_full_prefix_completion():
    """Full completion from prefix is possible."""
    typed = randomchars(allowed_chars, 1, 4)
    target = typed + randomchars(allowed_chars, 5, 10) 
    # To ensure there's only one possibility, others can't have the last char in typed.
    new_chars = ''.join(c for c in allowed_chars if c != typed[-1])
    possibilities = [target,
        *(randomchars(allowed_chars, 5, 15) for i in range(random.randint(2, 19)))]
    random.shuffle(possibilities)
    return {'typed': typed, 'possibilities': possibilities}

def mkset_partial_prefix_completion():
    """Partial completion from prefix is possible."""
    typed = randomchars(allowed_chars, 0, 3)
    common = typed + randomchars(allowed_chars, 1, 5)
    # Make sure there's one that uses a continuation the others don't, so full completion isn't possible.
    dropped_char = random.choice(allowed_chars)
    new_chars = ''.join(c for c in allowed_chars if c != dropped_char)
    possibilities = [common + dropped_char + randomchars(allowed_chars, 5, 10),
        *(common + randomchars(new_chars, 5, 10) for i in range(random.randint(2, 19)))]
    random.shuffle(possibilities)
    return {'typed': typed, 'possibilities': possibilities}

def mkset_typed_no_completion():
    """The user's typed something with no completion."""
    typed = randomchars(allowed_chars, 5, 10)
    # Don't allow choosing some char in the typed string.
    dropped_char = random.choice(typed)
    new_chars = ''.join(c for c in allowed_chars if c != dropped_char)
    possibilities = [randomchars(new_chars, 5, 15) for i in range(random.randint(2, 20))]
    random.shuffle(possibilities)
    return {'typed': typed, 'possibilities': possibilities}

def mkset_prefix_ambiguous():
    """There are multiple things that start with what the user's typed, and immediately diverge."""
    typed = randomchars(allowed_chars, 1, 5)
    p1 = typed + randomchars(allowed_chars, 5, 10)
    # Pick another possibility that immediately diverges from p1.
    p2_chars = ''.join(c for c in allowed_chars if c != p1[len(typed)])
    p2 = typed + randomchars(p2_chars, 5, 10)
    possibilities = [p1, p2,
        *(randomchars(allowed_chars, 5, 15) for i in range(random.randint(2, 18)))]
    random.shuffle(possibilities)
    return {'typed': typed, 'possibilities': possibilities}

def mkset_contain_ambiguous():
    """There's nothing that starts with what the user's typed, but two that contain it."""
    typed = randomchars(allowed_chars, 1, 5)
    p1 = randomchars(allowed_chars, 2, 5) + typed + randomchars(allowed_chars, 2, 5)
    # Pick another possibility that immediately diverges from p1.
    new_chars = ''.join(c for c in allowed_chars if c != p1[len(typed)])
    p2 = randomchars(new_chars, 2, 5) + typed + randomchars(new_chars, 2, 5)
    possibilities = [p1, p2,
        *(randomchars(new_chars, 5, 15) for i in range(random.randint(2, 18)))]
    random.shuffle(possibilities)
    return {'typed': typed, 'possibilities': possibilities}

def mkset_contain_completion():
    """There's nothing that starts with what the user's typed, but one that contains it."""
    typed = randomchars(allowed_chars, 1, 5)
    match = randomchars(allowed_chars, 2, 5) + typed + randomchars(allowed_chars, 2, 5)
    # Make sure nothing else can contain it.
    dropped_char = random.choice(typed)
    new_chars = ''.join(c for c in allowed_chars if c != dropped_char)
    possibilities = [match,
        *(randomchars(new_chars, 5, 15) for i in range(random.randint(2, 19)))]
    random.shuffle(possibilities)
    return {'typed': typed, 'possibilities': possibilities}

def mkset_sparse_ambiguous():
    """There's nothing that starts with what the user's typed, nothing that contains it,
    but two that contain all the characters."""
    typed = randomchars(allowed_chars, 2, 5)
    p1 = intersperse_chars(typed, randomchars(allowed_chars, 5, 15))
    p2 = intersperse_chars(typed, randomchars(allowed_chars, 5, 15))
    # Make sure nothing else can contain it.
    dropped_char = random.choice(typed)
    new_chars = ''.join(c for c in allowed_chars if c != dropped_char)
    possibilities = [p1, p2,
        *(randomchars(new_chars, 5, 15) for i in range(random.randint(2, 18)))]
    random.shuffle(possibilities)
    return {'typed': typed, 'possibilities': possibilities}

def mkset_sparse_completion():
    """There's nothing that starts with what the user's typed, nothing that contains it,
    but one that contains all the characters."""
    typed = randomchars(allowed_chars, 2, 5)
    match = intersperse_chars(typed, randomchars(allowed_chars, 5, 15))
    # Make sure nothing else can contain it.
    dropped_char = random.choice(typed)
    new_chars = ''.join(c for c in allowed_chars if c != dropped_char)
    possibilities = [match,
        *(randomchars(new_chars, 5, 15) for i in range(random.randint(2, 19)))]
    random.shuffle(possibilities)
    return {'typed': typed, 'possibilities': possibilities}

def randomchars(charset, lower, upper):
    return ''.join(random.choice(charset) for i in range(random.randint(lower, upper)))

def intersperse_chars(chars, string):
    final, string = string[0], string[1:]
    for c in chars:
        skip = random.randint(0, 3)
        final += c
        final += string[skip:]
        string = string[:skip]
    return final + string

if __name__ == '__main__': gendata()

Оригинал: “https://dev.to/yujiri8/comparing-an-algorithm-in-python-go-haskell-2olm”